From 97619bdc0519082ce29bfa27247ea74f2d445c2b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:34:49 +0300 Subject: [PATCH] Exported images w/ SQ season summary downloadable (#3272) --- app/components/Ability.tsx | 1 + app/components/Avatar.tsx | 32 +- app/components/BuildCard.tsx | 97 ++- app/components/Flag.tsx | 2 +- app/db/seed/core/reseason.ts | 14 + app/features/builds/routes/builds.$slug.tsx | 6 +- .../components-showcase/routes/components.tsx | 457 ++++++++++++- .../components/BuildGraphic.module.css | 122 ++++ .../img-export/components/BuildGraphic.tsx | 251 ++++++++ .../img-export/components/Graphic.module.css | 335 ++++++++++ .../img-export/components/Graphic.tsx | 283 ++++++++ .../components/ImageExportDialog.module.css | 24 + .../components/ImageExportDialog.tsx | 178 ++++++ .../SeasonSummaryGraphic.module.css | 387 +++++++++++ .../components/SeasonSummaryGraphic.tsx | 605 ++++++++++++++++++ .../TournamentResultsGraphic.module.css | 9 + .../components/TournamentResultsGraphic.tsx | 140 ++++ .../TournamentRunGraphic.module.css | 54 ++ .../components/TournamentRunGraphic.tsx | 197 ++++++ .../img-export/core/SeasonSummary.test.ts | 326 ++++++++++ app/features/img-export/core/SeasonSummary.ts | 195 ++++++ app/features/info/routes/support.tsx | 5 + app/features/mmr/SkillRepository.server.ts | 97 ++- .../PlayerStatRepository.server.ts | 401 +++++++++++- .../components/TierListGraphic.module.css | 80 +++ .../components/TierListGraphic.tsx | 64 ++ .../tier-list-maker/components/TierRow.tsx | 68 +- .../contexts/TierListContext.tsx | 16 +- .../tier-list-maker/hooks/useTierList.ts | 22 +- .../routes/tier-list-maker.module.css | 59 -- .../routes/tier-list-maker.tsx | 117 ++-- .../tier-list-maker-constants.ts | 7 + .../tier-list-maker-utils.test.ts | 66 ++ .../tier-list-maker/tier-list-maker-utils.ts | 63 +- app/features/trophies/routes/trophies.new.tsx | 3 +- .../user-page/UserRepository.server.ts | 15 + ...entifier.seasons.summary-graphic.server.ts | 228 +++++++ .../user-page/routes/u.$identifier.builds.tsx | 10 +- .../user-page/routes/u.$identifier.index.tsx | 1 + .../u.$identifier.seasons.summary-graphic.ts | 1 + .../routes/u.$identifier.seasons.tsx | 142 +++- app/features/user-page/user-page-schemas.ts | 6 + app/routes.ts | 4 + app/styles/vars.css | 16 +- app/utils/urls.ts | 30 + e2e/helpers/factories.ts | 1 + e2e/pages/user/user-seasons-page.ts | 43 ++ e2e/user-page.spec.ts | 75 +++ locales/da/common.json | 12 + locales/da/tier-list-maker.json | 4 +- locales/da/tournament.json | 6 + locales/da/user.json | 21 + locales/de/common.json | 12 + locales/de/tier-list-maker.json | 4 +- locales/de/tournament.json | 6 + locales/de/user.json | 21 + locales/en/common.json | 12 + locales/en/tier-list-maker.json | 4 +- locales/en/tournament.json | 6 + locales/en/user.json | 21 + locales/es-ES/common.json | 12 + locales/es-ES/tier-list-maker.json | 4 +- locales/es-ES/tournament.json | 6 + locales/es-ES/user.json | 23 + locales/es-US/common.json | 12 + locales/es-US/tier-list-maker.json | 4 +- locales/es-US/tournament.json | 6 + locales/es-US/user.json | 23 + locales/fr-CA/common.json | 12 + locales/fr-CA/tier-list-maker.json | 4 +- locales/fr-CA/tournament.json | 6 + locales/fr-CA/user.json | 23 + locales/fr-EU/common.json | 12 + locales/fr-EU/tier-list-maker.json | 4 +- locales/fr-EU/tournament.json | 6 + locales/fr-EU/user.json | 23 + locales/he/common.json | 12 + locales/he/tier-list-maker.json | 4 +- locales/he/tournament.json | 6 + locales/he/user.json | 23 + locales/it/common.json | 12 + locales/it/tier-list-maker.json | 4 +- locales/it/tournament.json | 6 + locales/it/user.json | 23 + locales/ja/common.json | 12 + locales/ja/tier-list-maker.json | 4 +- locales/ja/tournament.json | 6 + locales/ja/user.json | 17 + locales/ko/common.json | 12 + locales/ko/tier-list-maker.json | 4 +- locales/ko/tournament.json | 6 + locales/ko/user.json | 17 + locales/nl/common.json | 12 + locales/nl/tier-list-maker.json | 4 +- locales/nl/tournament.json | 6 + locales/nl/user.json | 21 + locales/pl/common.json | 12 + locales/pl/tier-list-maker.json | 4 +- locales/pl/tournament.json | 6 + locales/pl/user.json | 25 + locales/pt-BR/common.json | 12 + locales/pt-BR/tier-list-maker.json | 4 +- locales/pt-BR/tournament.json | 6 + locales/pt-BR/user.json | 23 + locales/ru/common.json | 12 + locales/ru/tier-list-maker.json | 4 +- locales/ru/tournament.json | 6 + locales/ru/user.json | 25 + locales/zh/common.json | 12 + locales/zh/tier-list-maker.json | 4 +- locales/zh/tournament.json | 6 + locales/zh/user.json | 17 + scripts/benchmark-db/cases.ts | 15 + scripts/benchmark-db/fixtures.ts | 4 +- 114 files changed, 5780 insertions(+), 292 deletions(-) create mode 100644 app/db/seed/core/reseason.ts create mode 100644 app/features/img-export/components/BuildGraphic.module.css create mode 100644 app/features/img-export/components/BuildGraphic.tsx create mode 100644 app/features/img-export/components/Graphic.module.css create mode 100644 app/features/img-export/components/Graphic.tsx create mode 100644 app/features/img-export/components/ImageExportDialog.module.css create mode 100644 app/features/img-export/components/ImageExportDialog.tsx create mode 100644 app/features/img-export/components/SeasonSummaryGraphic.module.css create mode 100644 app/features/img-export/components/SeasonSummaryGraphic.tsx create mode 100644 app/features/img-export/components/TournamentResultsGraphic.module.css create mode 100644 app/features/img-export/components/TournamentResultsGraphic.tsx create mode 100644 app/features/img-export/components/TournamentRunGraphic.module.css create mode 100644 app/features/img-export/components/TournamentRunGraphic.tsx create mode 100644 app/features/img-export/core/SeasonSummary.test.ts create mode 100644 app/features/img-export/core/SeasonSummary.ts create mode 100644 app/features/tier-list-maker/components/TierListGraphic.module.css create mode 100644 app/features/tier-list-maker/components/TierListGraphic.tsx create mode 100644 app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts create mode 100644 app/features/user-page/routes/u.$identifier.seasons.summary-graphic.ts create mode 100644 e2e/pages/user/user-seasons-page.ts diff --git a/app/components/Ability.tsx b/app/components/Ability.tsx index ffdc45b4b..7f997487f 100644 --- a/app/components/Ability.tsx +++ b/app/components/Ability.tsx @@ -7,6 +7,7 @@ import styles from "./Ability.module.css"; import { Image } from "./Image"; const sizeMap = { + HUGE: 64, MAIN: 42, SUB: 32, SUBTINY: 26, diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 2634bd0eb..8ae9989dd 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import type { Tables } from "~/db/tables"; import { useHydrated } from "~/hooks/useHydrated"; import { LRUCache } from "~/modules/cache"; -import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls"; +import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls"; import styles from "./Avatar.module.css"; const dimensions = { @@ -132,19 +132,23 @@ export function Avatar({ const identiconSource = identiconInput ?? user?.discordId ?? "unknown"; - const src = url - ? url - : user?.customAvatarUrl && !isErrored - ? user.customAvatarUrl - : user?.discordAvatar && !isErrored - ? discordAvatarUrl({ - discordAvatar: user.discordAvatar, - discordId: user.discordId, - size: size === "lg" || size === "xmd" ? "lg" : "sm", - }) - : isClient - ? generateIdenticon(identiconSource, dimensions[size], 7) - : BLANK_IMAGE_URL; + const userAvatarUrl = user + ? resolveAvatarUrl({ + customAvatarUrl: user.customAvatarUrl, + discordId: user.discordId, + discordAvatar: user.discordAvatar, + size: size === "lg" || size === "xmd" ? "lg" : "sm", + }) + : undefined; + + const avatarUrl = url ?? userAvatarUrl; + + const src = + avatarUrl && !isErrored + ? avatarUrl + : isClient + ? generateIdenticon(identiconSource, dimensions[size], 7) + : BLANK_IMAGE_URL; return (
diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index d71ce128c..0bdb5b892 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -1,10 +1,22 @@ import clsx from "clsx"; -import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react"; +import { + HardDriveDownload, + Lock, + MessageCircleMore, + SquarePen, + Trash, +} from "lucide-react"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; +import { + BuildGraphic, + type BuildGraphicOwner, +} from "~/features/img-export/components/BuildGraphic"; +import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog"; import type { Ability as AbilityType, BuildAbilitiesTuple, @@ -28,6 +40,7 @@ import { Ability } from "./Ability"; import styles from "./BuildCard.module.css"; import { LinkButton, SendouButton } from "./elements/Button"; import { SendouPopover } from "./elements/Popover"; +import { SendouSwitch } from "./elements/Switch"; import { FormWithConfirm } from "./FormWithConfirm"; import { Image } from "./Image"; import { LocaleTime } from "./LocaleTime"; @@ -48,11 +61,21 @@ interface BuildProps { modes: ModeShort[] | null; weapons: Array; }; - owner?: Pick; + owner?: Pick & + Partial< + Pick + >; + /** Set to false when the page context already shows the owner (e.g. their own builds page) */ + showOwner?: boolean; canEdit?: boolean; } -export function BuildCard({ build, owner, canEdit = false }: BuildProps) { +export function BuildCard({ + build, + owner, + showOwner = true, + canEdit = false, +}: BuildProps) { const user = useUser(); const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]); @@ -100,15 +123,15 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
- {owner && ( + {owner && showOwner ? ( <> {owner.username}
- )} - {owner?.plusTier ? ( + ) : null} + {owner?.plusTier && showOwner ? ( <> +{owner.plusTier}
@@ -180,6 +203,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { path={navIconUrl("analyzer")} /> + {owner ? : null} {description ? ( } + className={styles.smallText} + aria-label={t("common:imageExport.export")} + /> + } + heading={t("common:imageExport.export")} + filename={`build-${mySlugify(build.title)}`} + qrCodePath={analyzerPage({ + weaponId: build.weapons[0].weaponSplId, + abilities: build.abilities.flat(), + })} + settings={ + <> + + {t("common:imageExport.buildTitle")} + + + {t("common:imageExport.abilityPoints")} + + + {t("common:imageExport.abilityChunks")} + + + } + > + + + ); +} + function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) { const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId); diff --git a/app/components/Flag.tsx b/app/components/Flag.tsx index 9dbcf91c6..897691eeb 100644 --- a/app/components/Flag.tsx +++ b/app/components/Flag.tsx @@ -12,7 +12,7 @@ export function Flag({ const { i18n } = useTranslation(); return ( -
}) { ); diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index fa0a26048..2c26d0fb5 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -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: "It’s 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 ( +
+ Tournament Results Graphic + +
+ + + +
+
+ ); +} + +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 ( +
+ Tournament Run Graphic + +
+ + + +
+
+ ); +} + +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 ( +
+ Season Summary Graphic + +
+ + + +
+
+ ); +} + function FormMessageSection({ id }: { id: string }) { return (
diff --git a/app/features/img-export/components/BuildGraphic.module.css b/app/features/img-export/components/BuildGraphic.module.css new file mode 100644 index 000000000..074383681 --- /dev/null +++ b/app/features/img-export/components/BuildGraphic.module.css @@ -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; +} diff --git a/app/features/img-export/components/BuildGraphic.tsx b/app/features/img-export/components/BuildGraphic.tsx new file mode 100644 index 000000000..c0ce779a9 --- /dev/null +++ b/app/features/img-export/components/BuildGraphic.tsx @@ -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; + }; + 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 ( + + + {owner.username} + {owner.plusTier ? ( + +{owner.plusTier} + ) : null} + + } + subtitle={ + + } + trailing={ + build.modes && build.modes.length > 0 + ? build.modes.map((mode) => ( + + )) + : undefined + } + alignTrailingWithTitle + /> + {showTitle ? ( +
{build.title}
+ ) : null} +
+ {build.weapons.map((weapon) => ( +
+ {weapon.isTop500 ? ( + + ) : null} + +
+ ))} + {build.weapons.length === 1 ? ( +
+ {t(`weapons:MAIN_${build.weapons[0].weaponSplId}`)} +
+ ) : null} +
+
+ + + +
+ {showAbilityChunks ? ( + + ) : null} + {build.description ? ( +
{build.description}
+ ) : null} +
+ ); +} + +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" ? ( + + ) : null} + +
+ {subAbilitySegments(subAbilities).map((segment, segmentIndex) => ( +
+
+ {Array.from({ length: segment.count }, (_, index) => ( + + ))} +
+ {abilityPoints ? ( +
+ {apFromMap({ abilityPoints, ability: segment.ability })} + {t("analyzer:abilityPoints.short")} +
+ ) : null} +
+ ))} +
+ + ); +} + +function AbilityChunksRow({ abilities }: { abilities: BuildAbilitiesTuple }) { + const abilityChunks = getAbilityChunksMapAsArray(abilities); + + if (abilityChunks.length === 0) return null; + + return ( +
+ {abilityChunks.map(([ability, count]) => ( +
+ +
{count}
+
+ ))} +
+ ); +} + +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; +} diff --git a/app/features/img-export/components/Graphic.module.css b/app/features/img-export/components/Graphic.module.css new file mode 100644 index 000000000..f1d7ac739 --- /dev/null +++ b/app/features/img-export/components/Graphic.module.css @@ -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; + } +} diff --git a/app/features/img-export/components/Graphic.tsx b/app/features/img-export/components/Graphic.tsx new file mode 100644 index 000000000..e474eb492 --- /dev/null +++ b/app/features/img-export/components/Graphic.tsx @@ -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(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 ( +
+ {children} + {qrCodeUrl ? ( +
+
+ +
+
+ ) : null} +
+ ); +} + +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 ? ( +
{trailing}
+ ) : null; + + return ( +
+ +
+
+ {titleRow} + {alignTrailingWithTitle ? trailingContent : null} +
+ {subtitle} +
+ {alignTrailingWithTitle ? null : trailingContent} +
+ ); +} + +export function GraphicTeamsList({ children }: { children: React.ReactNode }) { + return
    {children}
; +} + +export function GraphicTeamRow({ + team, + leading, + highlighted = false, + className, + as: Element = "li", +}: { + team: GraphicTeam; + leading?: React.ReactNode; + highlighted?: boolean; + className?: string; + as?: "li" | "div"; +}) { + return ( + + {leading} +
+ + {typeof team.seed === "number" ? ( +
#{team.seed}
+ ) : null} +
+
+
{team.name}
+
+ {team.players.map((player) => ( + + ))} +
+
+
+ {team.weapons.map((weaponSplId, index) => ( +
+ + +
+ ))} +
+
+ ); +} + +/** + * 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 ( + + {player.countryCode ? ( + + ) : null} + {player.name} + + ); +} + +export function GraphicPlacementCell({ placement }: { placement: number }) { + return ( +
+ +
+ ); +} + +export function GraphicStatsRow({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function GraphicStat({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export function GraphicWonLost({ won, lost }: { won: number; lost: number }) { + return ( + <> + {won} + - + {lost} + + ); +} + +export function GraphicScore({ + ownScore, + opponentScore, +}: { + ownScore: number; + opponentScore: number; +}) { + return ( +
opponentScore ? styles.scoreWin : styles.scoreLoss, + )} + > + {ownScore}-{opponentScore} +
+ ); +} + +export function GraphicSectionDivider({ + children, + as: Element = "div", +}: { + children: React.ReactNode; + as?: "div" | "li"; +}) { + return {children}; +} + +export function GraphicFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function GraphicSiteUrl({ path }: { path: string }) { + return ( +
+ sendou.ink + {path} +
+ ); +} + +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; + } +} diff --git a/app/features/img-export/components/ImageExportDialog.module.css b/app/features/img-export/components/ImageExportDialog.module.css new file mode 100644 index 000000000..c6680c162 --- /dev/null +++ b/app/features/img-export/components/ImageExportDialog.module.css @@ -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; +} diff --git a/app/features/img-export/components/ImageExportDialog.tsx b/app/features/img-export/components/ImageExportDialog.tsx new file mode 100644 index 000000000..ef0eb35bf --- /dev/null +++ b/app/features/img-export/components/ImageExportDialog.tsx @@ -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 ( + + + + ); +} + +function ImageExportDialogContent({ + filename, + qrCodePath, + settings, + children, +}: Omit) { + const { t } = useTranslation(["common"]); + const { htmlThemeClass } = useTheme(); + const location = useLocation(); + const pageHasCustomTheme = usePageHasCustomTheme(); + const [themeSelection, setThemeSelection] = React.useState( + () => { + const mode = htmlThemeClass === "light" ? "light" : "dark"; + + return pageHasCustomTheme ? `${mode}-custom` : mode; + }, + ); + const [withQrCode, setWithQrCode] = React.useState(true); + const frameRef = React.useRef(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 ( +
+
+ + {THEME_SELECTIONS.filter( + (selection) => pageHasCustomTheme || !selection.needsCustomTheme, + ).map((selection) => ( + setThemeSelection(selection.value)} + > + {t(selection.translationKey)} + + ))} + + + {t("common:imageExport.qrCode")} + + {settings} +
+ } + onPress={handleDownload} + className="mx-auto" + > + {t("common:imageExport.download")} + +
+
+ + {children} + +
+
+
+ ); +} + +function usePageHasCustomTheme() { + const matches = useMatches(); + + return matches.some((match) => + Boolean( + (match.loaderData as { customTheme?: unknown } | undefined)?.customTheme, + ), + ); +} diff --git a/app/features/img-export/components/SeasonSummaryGraphic.module.css b/app/features/img-export/components/SeasonSummaryGraphic.module.css new file mode 100644 index 000000000..881e21ede --- /dev/null +++ b/app/features/img-export/components/SeasonSummaryGraphic.module.css @@ -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); +} diff --git a/app/features/img-export/components/SeasonSummaryGraphic.tsx b/app/features/img-export/components/SeasonSummaryGraphic.tsx new file mode 100644 index 000000000..9ba63f850 --- /dev/null +++ b/app/features/img-export/components/SeasonSummaryGraphic.tsx @@ -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 ( + + + {user.countryCode ? ( + + ) : null} + {user.name} + + } + subtitle={ + + } + trailing={ +
+ {t("user:seasons.season")} {season} +
+ } + /> +
+ +
+
+ {tier.name} + {tier.isPlus ? "+" : ""} +
+
{sp.toFixed(1)}SP
+
+ {t("user:seasons.peak")} {peakSp.toFixed(1)}SP +
+
+ {typeof soloRank === "number" ? ( +
+
#{soloRank}
+
+ {t("user:seasons.summary.soloRank")} +
+
+ ) : null} +
+ {teamRank ? ( +
+ {teamRank.team ? ( + + ) : null} +
+
+ {teamRank.team ? ( + + {teamRank.team.name} + + ) : null} +
+ {teamRank.sp.toFixed(1)}SP +
+
+
+ {teamRank.mates.map((mate) => ( + + ))} +
+
+ {typeof teamRank.rank === "number" ? ( +
+
#{teamRank.rank}
+
+ {t("user:seasons.summary.teamRank")} +
+
+ ) : null} +
+ ) : null} + + + + + + + + {clutch && clutch.total > 0 ? ( + + + + ) : null} + + {longestWinStreak} + + + {spProgression.length >= CHART_POINTS_NEEDED ? ( +
+ +
+ ) : null} + {bestStage ? ( +
+
+ {t("user:seasons.summary.bestStage")} +
+
+ {t(`game-misc:STAGE_${bestStage.stageId}`)}{" "} + + {Math.round(bestStage.winratePercentage)}% + +
+
+ ) : null} +
+
+
+ {t("user:seasons.summary.activity")} +
+ + +
+
+ {topWeapons.length > 0 ? ( +
+
+ {t("user:seasons.summary.topWeapons")} +
+
+ {topWeapons.map((weapon) => ( +
+ +
+ {Math.round(weapon.usagePercentage)}% +
+
+ ))} +
+
+ ) : null} + {shownMates.length > 0 ? ( +
+
+ {t("user:seasons.summary.topMates")} +
+
+ {shownMates.map((mate) => ( +
+ +
+ {mate.player.countryCode ? ( + + ) : null} + + {mate.player.name} + +
+
+ {t("user:seasons.summary.count.sets", { + count: mate.setsCount, + })} +
+
+ ))} +
+
+ ) : null} +
+
+ {bestSets.length > 0 ? ( + <> + + {t("user:seasons.summary.bestWins")} + +
    + {bestSets.map((set, index) => ( +
  1. + +
    +
    + {set.context} +
    +
    + {set.opponentPlayers.map((player) => ( + + ))} +
    +
    +
    +
    + {set.opponentSp.toFixed(1)} +
    +
    + {t("user:seasons.summary.opponentSp")} +
    +
    +
  2. + ))} +
+ + ) : null} + {bestTournament ? ( + <> + + {t("user:seasons.summary.bestTournament")} + +
+ + +
+
{bestTournament.name}
+
+ {t("calendar:count.teams", { + count: bestTournament.teamsCount, + })} +
+
+ {typeof bestTournament.tier === "number" ? ( + + ) : null} +
+ + ) : null} + {qrCodeUrl ? null : ( + +
+ {t("user:seasons.summary.count.sets", { + count: setsWon + setsLost, + })}{" "} + ·{" "} + {t("user:seasons.summary.count.maps", { + count: mapsWon + mapsLost, + })} +
+ +
+ )} +
+ ); +} + +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 ( + + + {/* presentation attributes, not CSS: the image export does not style elements inside defs */} + + + + + + + + + + + {maxSp.toFixed(1)}SP + + + {formatter.format(parseISO(points[0].date))} + + + {formatter.format(parseISO(points[points.length - 1].date))} + + + ); +} + +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 ( +
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const beforeSeason = day.getTime() < seasonFirstDay.getTime(); + + return ( +
+ ); + })} +
+ ); +} + +function ActivityLegend() { + const { t } = useTranslation(["user"]); + + return ( +
+
+
+ SendouQ +
+
+
+ {t("user:seasons.summary.activity.tournament")} +
+
+
+ {t("user:seasons.summary.activity.both")} +
+
+ ); +} + +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; + } +} diff --git a/app/features/img-export/components/TournamentResultsGraphic.module.css b/app/features/img-export/components/TournamentResultsGraphic.module.css new file mode 100644 index 000000000..98ddb4cea --- /dev/null +++ b/app/features/img-export/components/TournamentResultsGraphic.module.css @@ -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); +} diff --git a/app/features/img-export/components/TournamentResultsGraphic.tsx b/app/features/img-export/components/TournamentResultsGraphic.tsx new file mode 100644 index 000000000..fa7a1a455 --- /dev/null +++ b/app/features/img-export/components/TournamentResultsGraphic.tsx @@ -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 ( + + + + {teams.map((team) => ( + } + /> + ))} + + + + ); +} + +export function TournamentGraphicHeader({ + tournamentName, + startTime, + logoUrl, + tier, + organization, +}: { + tournamentName: string; + startTime: Date; + logoUrl?: string; + tier?: number; + organization?: { name: string; avatarUrl?: string }; +}) { + return ( + + {tournamentName} + {typeof tier === "number" ? : null} + + } + subtitle={ + + } + trailing={ + organization ? ( + <> + {organization.name} + + + ) : null + } + /> + ); +} + +export function TournamentGraphicFooter({ + teamsCount, + playersCount, + path, +}: { + teamsCount: number; + playersCount: number; + path: string; +}) { + const { t } = useTranslation(["calendar"]); + + return ( + +
+ {t("calendar:count.teams", { count: teamsCount })} ·{" "} + {t("calendar:count.players", { count: playersCount })} +
+ +
+ ); +} diff --git a/app/features/img-export/components/TournamentRunGraphic.module.css b/app/features/img-export/components/TournamentRunGraphic.module.css new file mode 100644 index 000000000..82c329fc3 --- /dev/null +++ b/app/features/img-export/components/TournamentRunGraphic.module.css @@ -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); +} diff --git a/app/features/img-export/components/TournamentRunGraphic.tsx b/app/features/img-export/components/TournamentRunGraphic.tsx new file mode 100644 index 000000000..fd76bb6af --- /dev/null +++ b/app/features/img-export/components/TournamentRunGraphic.tsx @@ -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 ( + + + + + + + + {typeof seed === "number" ? ( + {seed} + ) : null} + + + + + + + + {team.placement === 1 && seriesWins ? ( + + + {seriesWins.latest ? ( + + ) : null} + + + {seriesWins.totalCount} + + + + ) : null} + + {matches.map((match, index) => { + const previousMatch = matches[index - 1]; + const qualifiedForBracket = + previousMatch && previousMatch.bracketName !== match.bracketName; + + return ( + + {qualifiedForBracket ? ( + + + {t("tournament:run.qualifiedFor", { + bracket: match.bracketName, + })} + + ) : null} + +
+ {match.roundName} +
+ +
+ } + /> + + ); + })} + + + + ); +} + +function SeriesWinStat({ + label, + win, +}: { + label: string; + win: TournamentRunGraphicSeriesWin; +}) { + return ( + +
+
{win.name}
+ +
+
+ ); +} diff --git a/app/features/img-export/core/SeasonSummary.test.ts b/app/features/img-export/core/SeasonSummary.test.ts new file mode 100644 index 000000000..dd48ace9c --- /dev/null +++ b/app/features/img-export/core/SeasonSummary.test.ts @@ -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); + }); +}); diff --git a/app/features/img-export/core/SeasonSummary.ts b/app/features/img-export/core/SeasonSummary.ts new file mode 100644 index 000000000..568b77778 --- /dev/null +++ b/app/features/img-export/core/SeasonSummary.ts @@ -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> + > + >, +): { 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( + 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, + ); +} diff --git a/app/features/info/routes/support.tsx b/app/features/info/routes/support.tsx index 27ada2e63..8498e2aa5 100644 --- a/app/features/info/routes/support.tsx +++ b/app/features/info/routes/support.tsx @@ -66,6 +66,11 @@ const PERKS = [ name: "previewQ", extraInfo: false, }, + { + tier: 2, + name: "seasonSummaryImage", + extraInfo: true, + }, { tier: 2, name: "userShortLink", diff --git a/app/features/mmr/SkillRepository.server.ts b/app/features/mmr/SkillRepository.server.ts index 9e28e59b2..f7eecb86f 100644 --- a/app/features/mmr/SkillRepository.server.ts +++ b/app/features/mmr/SkillRepository.server.ts @@ -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`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> { + 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`max("Skill"."groupMatchId" is not null)`.as("playedSq"), + sql`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`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"); +} diff --git a/app/features/sendouq-match/PlayerStatRepository.server.ts b/app/features/sendouq-match/PlayerStatRepository.server.ts index e5d93e45a..e6b7009b9 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.ts @@ -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> { + 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`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("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("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().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("TopEightLatestSkill.ordinal").as("average"), + ) + .as("topEightAvgOrdinal"), + ]) + .where("Skill.userId", "=", userId) + .where("Skill.season", "=", season) + .execute(); +} + export function upsertPlayerResults( results: Tables["PlayerResult"][], trx?: Transaction, @@ -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`sum("GroupMatchMap"."winnerGroupId" = "OwnMember"."groupId")`.as( + "ownScore", + ), + sql`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().as("count")) + .whereRef("SetGame.matchId", "=", "TournamentSet.tournamentMatchId") + .whereRef( + "SetGame.winnerTeamId", + side === "own" ? "=" : "!=", + "TournamentSet.ownTeamId", + ) + .$asScalar() + .$notNull(); +} diff --git a/app/features/tier-list-maker/components/TierListGraphic.module.css b/app/features/tier-list-maker/components/TierListGraphic.module.css new file mode 100644 index 000000000..6888890a7 --- /dev/null +++ b/app/features/tier-list-maker/components/TierListGraphic.module.css @@ -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); +} diff --git a/app/features/tier-list-maker/components/TierListGraphic.tsx b/app/features/tier-list-maker/components/TierListGraphic.tsx new file mode 100644 index 000000000..1aedfdf30 --- /dev/null +++ b/app/features/tier-list-maker/components/TierListGraphic.tsx @@ -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; + showTierHeaders: boolean; +}) { + const { t } = useTranslation(["tier-list-maker"]); + + return ( + + {title ?
{title}
: null} + {author ? ( +
+ {t("tier-list-maker:by")} + + {author.username} +
+ ) : null} +
+ {tiers.map((tier) => ( +
+ {showTierHeaders ? ( +
+ + {tier.name} + +
+ ) : null} +
+ {tier.items.map((item) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index fece071cb..c47db0b8e 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -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) {
- {items.length === 0 && !screenshotMode ? ( + {items.length === 0 ? (
{isClickMode ? t("tier-list-maker:clickToAdd") @@ -180,28 +172,26 @@ export function TierRow({ tier }: TierRowProps) { ) : null}
- {!screenshotMode ? ( -
- - -
- ) : null} +
+ + +
); } @@ -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; -} diff --git a/app/features/tier-list-maker/contexts/TierListContext.tsx b/app/features/tier-list-maker/contexts/TierListContext.tsx index 9a28cdc55..0206773e3 100644 --- a/app/features/tier-list-maker/contexts/TierListContext.tsx +++ b/app/features/tier-list-maker/contexts/TierListContext.tsx @@ -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 & { - screenshotMode: boolean; - setScreenshotMode: (value: boolean) => void; -}; +type TierListContextType = ReturnType; const TierListContext = createContext(null); export function TierListProvider({ children }: { children: ReactNode }) { const state = useTierList(); - const [screenshotMode, setScreenshotMode] = useState(false); return ( - + {children} ); diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 70cdc63f1..29c08d07f 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -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(() => { - 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( diff --git a/app/features/tier-list-maker/routes/tier-list-maker.module.css b/app/features/tier-list-maker/routes/tier-list-maker.module.css index 23868cc8c..f555a704f 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.module.css +++ b/app/features/tier-list-maker/routes/tier-list-maker.module.css @@ -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; -} diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index 54f5150da..1fc1c1b1a 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -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(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 (
@@ -160,13 +135,7 @@ function TierListMakerContent() { }> {t("tier-list-maker:addTier")} - } - > - {t("tier-list-maker:download")} - +
@@ -180,30 +149,7 @@ function TierListMakerContent() { onDragEnd={handleDragEnd} >
-
- {title || !screenshotMode ? ( - setTitle(e.target.value)} - placeholder={t("tier-list-maker:titlePlaceholder")} - className={clsx(styles.titleInput, "plain")} - /> - ) : null} - {screenshotMode && title && user ? ( -
-
{t("tier-list-maker:by")}
-
- - {user.username} -
-
- ) : null} +
{state.tiers.map((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 ( + }> + {t("common:imageExport.export")} + + } + heading={t("common:imageExport.export")} + filename="tier-list" + qrCodePath={tierListMakerPathWithState({ + state, + title, + showTierHeaders, + })} + settings={ + <> + setTitle(e.target.value)} + placeholder={t("tier-list-maker:title")} + aria-label={t("tier-list-maker:title")} + /> + {user ? ( + + {t("tier-list-maker:showUsername")} + + ) : null} + + } + > + ({ + ...tier, + items: getItemsInTier(tier.id), + }))} + showTierHeaders={showTierHeaders} + /> + + ); +} + function ResetPopover({ handleReset }: { handleReset: () => void }) { const { t } = useTranslation(["tier-list-maker", "common"]); diff --git a/app/features/tier-list-maker/tier-list-maker-constants.ts b/app/features/tier-list-maker/tier-list-maker-constants.ts index 573d0cbf4..dc15c4a79 100644 --- a/app/features/tier-list-maker/tier-list-maker-constants.ts +++ b/app/features/tier-list-maker/tier-list-maker-constants.ts @@ -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)" }, diff --git a/app/features/tier-list-maker/tier-list-maker-utils.test.ts b/app/features/tier-list-maker/tier-list-maker-utils.test.ts index 973e8aa8c..aa4371eca 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.test.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.test.ts @@ -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"); diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index c754da7b5..3f3c1cb8f 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -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(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(compressed: string) { const json = decompressFromBase64(compressed); if (json === null) return null; @@ -66,3 +121,7 @@ export function decompress(compressed: string) { return null; } } + +function compress(obj: T) { + return compressToBase64(JSON.stringify(obj), { urlSafe: true }); +} diff --git a/app/features/trophies/routes/trophies.new.tsx b/app/features/trophies/routes/trophies.new.tsx index 4f3b52ba6..c038773f3 100644 --- a/app/features/trophies/routes/trophies.new.tsx +++ b/app/features/trophies/routes/trophies.new.tsx @@ -389,7 +389,8 @@ function ModelField({ {(["light", "dark"] as const).map((theme) => (
{t(`trophies:new.form.preview.${theme}`)} diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 40fb190a0..c800dfbaf 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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(); + + 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 }) => [ diff --git a/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts new file mode 100644 index 000000000..bf1b10af3 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts @@ -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; + +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 }; +} diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 172738c70..4cbbb115e 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -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 ? (
{builds.map((build) => ( - + ))}
) : ( diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 05152cc4d..59c7bc6ae 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -57,6 +57,7 @@ export const handle: SendouRouteHandle = { "weapons", "gear", "game-badges", + "analyzer", "trophies", ], }; diff --git a/app/features/user-page/routes/u.$identifier.seasons.summary-graphic.ts b/app/features/user-page/routes/u.$identifier.seasons.summary-graphic.ts new file mode 100644 index 000000000..9ffb02002 --- /dev/null +++ b/app/features/user-page/routes/u.$identifier.seasons.summary-graphic.ts @@ -0,0 +1 @@ +export { loader } from "../loaders/u.$identifier.seasons.summary-graphic.server"; diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx index 468156067..87609108d 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.tsx +++ b/app/features/user-page/routes/u.$identifier.seasons.tsx @@ -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)} /> - +
+ + +
{data.currentOrdinal ? (
} + > + {t("user:seasons.summary.export")} + + } + > + {t("user:seasons.summary.export.supporterPerk")} + + ); + } + + return ( + + ); +} + +function SeasonSummaryExportDialog({ + profileUser, + season, +}: { + profileUser: UserPageLoaderData["user"]; + season: number; +}) { + const { t } = useTranslation(["user"]); + const fetcher = useFetcher(); + + const handleOpen = () => { + if (fetcher.state === "idle" && !fetcher.data) { + fetcher.load(userSeasonSummaryGraphicPage({ user: profileUser, season })); + } + }; + + const data = fetcher.data; + + return ( + } + onPress={handleOpen} + > + {t("user:seasons.summary.export")} + + } + heading={t("user:seasons.summary.export")} + filename={`season-${season}-summary`} + qrCodePath={userSeasonsPage({ user: profileUser, season })} + > + {data ? ( + + ) : null} + + ); +} + function SeasonHeader({ seasonViewed, seasonsParticipatedIn, diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 7acad05fd..b7daf32c1 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -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, diff --git a/app/routes.ts b/app/routes.ts index 2ed096d53..f3c288f23 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -83,6 +83,10 @@ export default [ "edit-widgets", "features/user-page/routes/u.$identifier.edit-widgets.tsx", ), + route( + "seasons/summary-graphic", + "features/user-page/routes/u.$identifier.seasons.summary-graphic.ts", + ), route("seasons", "features/user-page/routes/u.$identifier.seasons.tsx", [ index("features/user-page/routes/u.$identifier.seasons.index.tsx"), route( diff --git a/app/styles/vars.css b/app/styles/vars.css index b7f3f298c..793bcaaa0 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -15,8 +15,13 @@ These should not be consumed directly, only used as a base for other vars! The hue and chroma values should not be edited manually, instead use oklch-gamut.ts to generate valid color palettes! + +A subtree can force a color scheme regardless of the html class with +[data-theme="dark"|"light"] and opt out of an active custom theme with +[data-default-theme] (e.g. image export previews) */ -html { +html, +[data-default-theme] { --_base-h: 268; --_base-c-0: 0; --_base-c-1: 0.02357547860966049; @@ -81,7 +86,7 @@ Any changes here NEED to be reflected in oklch-gamut.ts as well */ html.dark, html.dark [data-custom-theme], -.dark-preview { +[data-theme="dark"] { --color-base-0: oklch(100% var(--_base-c-0) var(--_base-h)); --color-base-1: oklch(95% var(--_base-c-1) var(--_base-h)); --color-base-2: oklch(90% var(--_base-c-2) var(--_base-h)); @@ -133,7 +138,7 @@ html.dark [data-custom-theme], html.light, html.light [data-custom-theme], -.light-preview { +[data-theme="light"] { --color-base-0: oklch(17% var(--_base-c-7) var(--_base-h)); --color-base-1: oklch(25% var(--_base-c-6) var(--_base-h)); --color-base-2: oklch(32% var(--_base-c-5) var(--_base-h)); @@ -187,10 +192,9 @@ html.light [data-custom-theme], These are the vars you mainly want to use */ html, -/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */ +/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] and [data-theme] re-declare theme vars for subtrees; different properties than the html.dark/light blocks so no real override */ [data-custom-theme], -.dark-preview, -.light-preview { +[data-theme] { --color-text: var(--color-base-0); --color-text-high: var(--color-base-3); --color-text-inverse: var(--color-base-7); diff --git a/app/utils/urls.ts b/app/utils/urls.ts index ab46ff591..3c67cd50e 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -39,6 +39,29 @@ export const discordAvatarUrl = ({ discordAvatar }.webp${size === "lg" ? "?size=240" : "?size=80"}`; +/** + * Resolves the avatar image url of an user, preferring their custom avatar over + * the Discord one. Returns undefined if the user has neither. + */ +export const resolveAvatarUrl = ({ + customAvatarUrl, + discordId, + discordAvatar, + size, +}: { + customAvatarUrl?: string | null; + discordId: string; + discordAvatar?: string | null; + size: "lg" | "sm"; +}) => { + if (customAvatarUrl) return customAvatarUrl; + if (discordAvatar) { + return discordAvatarUrl({ discordId, discordAvatar, size }); + } + + return undefined; +}; + export const SENDOU_INK_BASE_URL = "https://sendou.ink"; export const BADGES_DOC_LINK = @@ -187,6 +210,13 @@ export const userSeasonsPage = ({ `${userPage(user)}/seasons${ typeof season === "number" ? `?season=${season}` : "" }`; +export const userSeasonSummaryGraphicPage = ({ + user, + season, +}: { + user: UserLinkArgs; + season: number; +}) => `${userPage(user)}/seasons/summary-graphic?season=${season}`; export const userSeasonsStatsPage = ({ user, season, diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 538657efe..d2fa61d5e 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -31,6 +31,7 @@ export async function loadFactories(parallelIndex: number) { return { backdate: (await import("~/db/seed/core/backdate")).backdate, + reseason: (await import("~/db/seed/core/reseason")).reseason, ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"), ArtFactory: await import("~/db/seed/factories/ArtFactory"), AssociationFactory: await import("~/db/seed/factories/AssociationFactory"), diff --git a/e2e/pages/user/user-seasons-page.ts b/e2e/pages/user/user-seasons-page.ts new file mode 100644 index 000000000..58792f478 --- /dev/null +++ b/e2e/pages/user/user-seasons-page.ts @@ -0,0 +1,43 @@ +import type { Page } from "@playwright/test"; +import { userSeasonsPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; + +/** A user profile's `/seasons` page, including the season summary image export. */ +export class UserSeasonsPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + exportImageButton: page.getByRole("button", { name: "Export image" }), + exportDialog: page.getByRole("dialog"), + downloadButton: page.getByRole("button", { name: "Download" }), + supporterPerkExplanation: page.getByText(/supporter perk/), + }; + } + + async goto(discordId: string, season?: number) { + await navigate({ + page: this.page, + url: userSeasonsPage({ user: { discordId }, season }), + }); + } + + async openExportDialog() { + await this.locators.exportImageButton.click(); + } + + exportDialogText(content: string) { + return this.locators.exportDialog.getByText(content); + } + + async downloadExportedImage() { + const downloadPromise = this.page.waitForEvent("download"); + await this.locators.exportDialog + .getByRole("button", { name: "Download" }) + .click(); + + return downloadPromise; + } +} diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index 39aa87855..65526d5e5 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -1,9 +1,18 @@ +import { addDays } from "date-fns"; import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; +import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import type { Factories } from "./helpers/factories"; import { expect, impersonate, isNotVisible, test } from "./helpers/playwright"; import { SettingsPage } from "./pages/settings/settings-page"; import { UserEditProfilePage } from "./pages/user/user-edit-profile-page"; import { UserPage } from "./pages/user/user-page"; +import { UserSeasonsPage } from "./pages/user/user-seasons-page"; + +/** The only season the e2e seasons list has finished, i.e. the exportable one. */ +const FINISHED_SEASON = 0; test.describe("User page", () => { test("uses badge pagination", async ({ page, factories }) => { @@ -147,6 +156,43 @@ test.describe("User page", () => { await expect(settings.hasCustomTheme()).resolves.toBe(false); }); + test("exports season summary image as a supporter", async ({ + page, + factories, + }) => { + await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 }); + await playFinishedSeason(factories, ADMIN_ID); + + await impersonate(page); + + const seasonsPage = new UserSeasonsPage(page); + await seasonsPage.goto(ADMIN_DISCORD_ID); + await seasonsPage.openExportDialog(); + + await expect(seasonsPage.exportDialogText("Best win streak")).toBeVisible(); + + const download = await seasonsPage.downloadExportedImage(); + expect(download.suggestedFilename()).toBe( + `season-${FINISHED_SEASON}-summary.png`, + ); + }); + + test("shows supporter perk explanation instead of exporting for non-supporter mid-season", async ({ + page, + factories, + }) => { + await playFinishedSeason(factories, NZAP_TEST_ID); + + await impersonate(page, NZAP_TEST_ID); + + const seasonsPage = new UserSeasonsPage(page); + await seasonsPage.goto(NZAP_TEST_DISCORD_ID); + await seasonsPage.openExportDialog(); + + await expect(seasonsPage.locators.supporterPerkExplanation).toBeVisible(); + await isNotVisible(seasonsPage.locators.downloadButton); + }); + test("edits weapon pool", async ({ page, factories }) => { await factories.UserFactory.grant(ADMIN_ID, { weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({ @@ -175,3 +221,32 @@ test.describe("User page", () => { } }); }); + +/** + * Plays the user through a whole season of SendouQ, ending it with a calculated + * (i.e. non-approximate) skill. The matches are spread over the days of the only + * finished season so that the season summary has something to show. + */ +async function playFinishedSeason(factories: Factories, userId: number) { + const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 1); + const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE); + + const ownGroup = [userId, ...mates.map((mate) => mate.id)]; + const opposingGroup = enemies.map((enemy) => enemy.id); + const { starts } = Seasons.nthToDateRange(FINISHED_SEASON); + + for (let index = 0; index < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; index++) { + // alpha wins every map, so which side the user is on decides the set + const userWon = index % 3 !== 0; + + await factories.SQMatchFactory.create( + { + alphaUserIds: userWon ? ownGroup : opposingGroup, + bravoUserIds: userWon ? opposingGroup : ownGroup, + }, + { isConcluded: true, createdAt: addDays(starts, index) }, + ); + } + + await factories.reseason(FINISHED_SEASON); +} diff --git a/locales/da/common.json b/locales/da/common.json index ed626473b..112f3a52c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Bliv medlem", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Tilpas farver", "custom.colors.bg": "Baggrund", "custom.colors.bg-darker": "Mørkere baggrund", diff --git a/locales/da/tier-list-maker.json b/locales/da/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/da/tier-list-maker.json +++ b/locales/da/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/da/tournament.json b/locales/da/tournament.json index dfaf4eda4..5b0deab6d 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -140,6 +140,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Der p.t. er ingen direkte udsendelser af denne turnering", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Vundne sæt", "team.mapWins": "Sejr på baner", "team.seed": "Seed", diff --git a/locales/da/user.json b/locales/da/user.json index a93cf6e42..f908c7e83 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -186,6 +186,27 @@ "seasons.noReportedWeapons": "Der er endnu ikke blevet indrapporteret nogle våben", "seasons.clickARow": "Tryk på en række for at se våbenbrugsstatistikker.", "seasons.loading": "indlæser...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Ændr sortering", "builds.sorting.header": "Ændr sæt-sortering", "builds.sorting.backToDefaults": "Nulstil visning", diff --git a/locales/de/common.json b/locales/de/common.json index 08c3d7504..4b9761897 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Beitreten", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Eigene Farben", "custom.colors.bg": "Hintergrund", "custom.colors.bg-darker": "Hintergrund dunkler", diff --git a/locales/de/tier-list-maker.json b/locales/de/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/de/tier-list-maker.json +++ b/locales/de/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 3d4d92f2f..4788bfee6 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -140,6 +140,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Aktuell sind keine Livestreams von diesem Turnier verfügbar", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Set-Siege", "team.mapWins": "Arenen-Siege", "team.seed": "Seed", diff --git a/locales/de/user.json b/locales/de/user.json index 1de21461a..842bbdbb7 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -186,6 +186,27 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/en/common.json b/locales/en/common.json index e868ddb7c..8afa07bef 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -149,6 +149,16 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Join", + "imageExport.export": "Export image", + "imageExport.download": "Download", + "imageExport.theme.light": "Light", + "imageExport.theme.dark": "Dark", + "imageExport.theme.lightCustom": "Light (custom)", + "imageExport.theme.darkCustom": "Dark (custom)", + "imageExport.qrCode": "QR code", + "imageExport.buildTitle": "Build title", + "imageExport.abilityPoints": "Ability points", + "imageExport.abilityChunks": "Ability chunks", "host": "Host", "seed": "Seed {{number}}", "actions.nevermind": "Nevermind", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "Log-in via Discord bot", "support.perk.useBotToLogIn.extra": "Request a log-in link from the Lohi bot as an alternative to the normal website log-in", "support.perk.earlyAccess": "Occasional early access to features", + "support.perk.seasonSummaryImage": "Export any season's summary image", + "support.perk.seasonSummaryImage.extra": "Export a summary image of any SendouQ season you played. Everyone can export the latest finished season's image during the off-season.", "custom.colors.title": "Custom colors", "custom.colors.bg": "Background", "custom.colors.bg-darker": "Background darker", diff --git a/locales/en/tier-list-maker.json b/locales/en/tier-list-maker.json index 1961c941e..6df8867b8 100644 --- a/locales/en/tier-list-maker.json +++ b/locales/en/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "Add tier", - "download": "Download (.png)", "mainWeapons": "Main Weapons", "subWeapons": "Sub Weapons", "specialWeapons": "Special Weapons", @@ -18,7 +17,8 @@ "resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.", "noDuplicates": "No duplicates", "showTierHeaders": "Show tier headers", - "titlePlaceholder": "Click to add title...", + "title": "Title", + "showUsername": "Show username", "by": "Made by", "custom": "Custom" } diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 7f3b1c390..b1747cf4f 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -140,6 +140,12 @@ "finalize.receivingTeam.label": "Receiving team", "finalize.receivingTeam.placeholder": "Select team…", "streams.none": "No live streams of this tournament available currently", + "run.sets": "Sets", + "run.maps": "Maps", + "run.qualifiedFor": "Advanced to {{bracket}}", + "run.firstTitle": "First title", + "run.latestTitle": "Latest title", + "run.seriesTitles": "Series titles", "team.setWins": "Set wins", "team.mapWins": "Map wins", "team.seed": "Seed", diff --git a/locales/en/user.json b/locales/en/user.json index e275369e5..1b4775ea1 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -186,6 +186,27 @@ "seasons.noReportedWeapons": "No reported weapons yet", "seasons.clickARow": "Click a row to see weapon usage stats", "seasons.loading": "Loading...", + "seasons.summary.sets": "Sets", + "seasons.summary.maps": "Maps", + "seasons.summary.winStreak": "Best win streak", + "seasons.summary.soloRank": "Solo rank", + "seasons.summary.teamRank": "Team rank", + "seasons.summary.activity": "Activity", + "seasons.summary.activity.tournament": "Tournament", + "seasons.summary.activity.both": "Both", + "seasons.summary.bestStage": "Best stage", + "seasons.summary.topWeapons": "Top weapons", + "seasons.summary.topMates": "Top teammates", + "seasons.summary.clutch": "Clutch", + "seasons.summary.bestWins": "Best wins", + "seasons.summary.bestTournament": "Best tournament", + "seasons.summary.opponentSp": "Opponent SP", + "seasons.summary.count.sets_one": "{{count}} set", + "seasons.summary.count.sets_other": "{{count}} sets", + "seasons.summary.count.maps_one": "{{count}} map", + "seasons.summary.count.maps_other": "{{count}} maps", + "seasons.summary.export": "Export image", + "seasons.summary.export.supporterPerk": "Exporting this season's summary image is a supporter perk. Everyone can export the latest finished season's image during the off-season.", "builds.sorting.changeButton": "Change sorting", "builds.sorting.header": "Change build sorting", "builds.sorting.backToDefaults": "Back to defaults", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index c1367a657..ef9be99dc 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -149,6 +149,16 @@ "actions.outlined": "Con borde", "actions.noOutline": "Sin borde", "actions.join": "Unirse", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Olvídalo", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "Iniciar sesión con el bot de Discord", "support.perk.useBotToLogIn.extra": "Solicita un enlace de inicio de sesión al bot Lohi como alternativa a iniciar sesión desde la web.", "support.perk.earlyAccess": "Acceso anticipado a nuevas funciones", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Colores personalizados", "custom.colors.bg": "Fondo", "custom.colors.bg-darker": "Fondo más oscuro", diff --git a/locales/es-ES/tier-list-maker.json b/locales/es-ES/tier-list-maker.json index 17bcc50ac..f90a79497 100644 --- a/locales/es-ES/tier-list-maker.json +++ b/locales/es-ES/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "Añadir tier", - "download": "Descargar (.png)", "mainWeapons": "Armas principales", "subWeapons": "Armas secundarias", "specialWeapons": "Armas especiales", @@ -18,7 +17,8 @@ "resetConfirmation": "¿Seguro que quieres reiniciar la tier list? Esto eliminará todos los elementos y restaurará las tiers por defecto.", "noDuplicates": "", "showTierHeaders": "Mostrar encabezados de las tiers", - "titlePlaceholder": "Haz clic para añadir un título...", + "title": "", + "showUsername": "", "by": "Creado por", "custom": "Personalizado" } diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 1b55f4b36..77d3df3ff 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "Equipo receptor", "finalize.receivingTeam.placeholder": "Seleccionar equipo...", "streams.none": "No hay streams disponibles para este torneo al momento", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Victorias de sets", "team.mapWins": "Victorias de mapas", "team.seed": "Colocado", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index c8869db9f..f935de997 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "No se han informado armas", "seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas", "seasons.loading": "Cargando...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Cambiar orden", "builds.sorting.header": "Cambiar orden de las builds", "builds.sorting.backToDefaults": "Volver por defecto", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index eb9cc1e13..f6de7af79 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Unirse", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Cancelar", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Colores personalizados", "custom.colors.bg": "Fondo", "custom.colors.bg-darker": "Fondo más oscuro", diff --git a/locales/es-US/tier-list-maker.json b/locales/es-US/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/es-US/tier-list-maker.json +++ b/locales/es-US/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 730191641..6e04fcfda 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "No hay streams disponibles para este torneo al momento", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Victorias de sets", "team.mapWins": "Victorias de juegos", "team.seed": "Colocado", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 21358adf4..664981f95 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "No se han informado armas", "seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas", "seasons.loading": "Cargando...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Cambiar ordenación", "builds.sorting.header": "Cambiar ordenación de builds", "builds.sorting.backToDefaults": "Volver a los valores predeterminados", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index cc195a8ae..db450b543 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Joindre", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Couleurs personalisées", "custom.colors.bg": "Arrière-plan", "custom.colors.bg-darker": "Arrière-plan sombre", diff --git a/locales/fr-CA/tier-list-maker.json b/locales/fr-CA/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/fr-CA/tier-list-maker.json +++ b/locales/fr-CA/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index dfc02bfc7..a567af998 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Set wins", "team.mapWins": "Map wins", "team.seed": "Seed", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 0ebccd81a..da2712757 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 109bbe446..262b476d2 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -149,6 +149,16 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Joindre", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Laisser tomber", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "Se connecter via le bot Discord", "support.perk.useBotToLogIn.extra": "Demander un lien de connexion au bot Lohi comme alternative à la connexion normale au site Web", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Couleurs personalisées", "custom.colors.bg": "Arrière-plan", "custom.colors.bg-darker": "Arrière-plan sombre", diff --git a/locales/fr-EU/tier-list-maker.json b/locales/fr-EU/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/fr-EU/tier-list-maker.json +++ b/locales/fr-EU/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index bc11e3d1e..5dfdd1033 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Set gagné", "team.mapWins": "Map gagnée", "team.seed": "Seed", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 6ab57bff4..80a1346f2 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "Aucune arme a été reporté", "seasons.clickARow": "Cliquez sur une ligne pour voir les statistiques d'utilisation des armes", "seasons.loading": "Chargement...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Changer le tri", "builds.sorting.header": "Modifier le tri des builds", "builds.sorting.backToDefaults": "Revenir par défaut", diff --git a/locales/he/common.json b/locales/he/common.json index 9692ee650..8f48b66e0 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "הצטרפות", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "צבעים מותאמים אישית", "custom.colors.bg": "רקע", "custom.colors.bg-darker": "רקע חשוך", diff --git a/locales/he/tier-list-maker.json b/locales/he/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/he/tier-list-maker.json +++ b/locales/he/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 808cd9a5b..cc77dc741 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "אין שידורים חיים לטורניר זה כרגע", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "נצחונות סטים", "team.mapWins": "נצחונות במפות", "team.seed": "דירוג", diff --git a/locales/he/user.json b/locales/he/user.json index 592912939..c1c016fdd 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_two": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_two": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/it/common.json b/locales/it/common.json index 537c5afb5..73d395e4b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -149,6 +149,16 @@ "actions.outlined": "Contornato", "actions.noOutline": "Nessun contorno", "actions.join": "Entra", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Non importa", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Colori personalizzati", "custom.colors.bg": "Sfondo", "custom.colors.bg-darker": "Sfondo più scuro", diff --git a/locales/it/tier-list-maker.json b/locales/it/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/it/tier-list-maker.json +++ b/locales/it/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 8e5d1b208..a7b63cd84 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Nessuna livestream di questo torneo disponibile al momento", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Set vinti", "team.mapWins": "Mappe vinte", "team.seed": "Seed", diff --git a/locales/it/user.json b/locales/it/user.json index 0514319d9..8bd9658a2 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "Nessun'arma riportata", "seasons.clickARow": "Clicca una riga per visualizzare le statistiche d'uso delle armi", "seasons.loading": "Caricamento...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Cambia ordinamento", "builds.sorting.header": "Cambia ordinamento build", "builds.sorting.backToDefaults": "Torna al default", diff --git a/locales/ja/common.json b/locales/ja/common.json index cf8b5f538..c4cdb53f1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -149,6 +149,16 @@ "actions.outlined": "アウトラインあり", "actions.noOutline": "アウトラインなし", "actions.join": "参加する", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "部屋を建てる", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "Discord ボットを使用してログイン", "support.perk.useBotToLogIn.extra": "Lohi ボットから直接ログインできる URL を貰える", "support.perk.earlyAccess": "たまに新機能の事前お試し", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "カスタムカラー", "custom.colors.bg": "背景", "custom.colors.bg-darker": "背景 暗め", diff --git a/locales/ja/tier-list-maker.json b/locales/ja/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/ja/tier-list-maker.json +++ b/locales/ja/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 1f750d1e5..9e32f75a5 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -136,6 +136,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "現在このトーナメントのストリーミングはありません", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "勝利セット", "team.mapWins": "勝利マップ", "team.seed": "シード", diff --git a/locales/ja/user.json b/locales/ja/user.json index f6258057a..7fa933268 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -186,6 +186,23 @@ "seasons.noReportedWeapons": "報告された武器がありません", "seasons.clickARow": "武器の使用統計を見るには行を選択してください", "seasons.loading": "読み込み中...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "並べ替え変更", "builds.sorting.header": "ギアの並べ替えを変更", "builds.sorting.backToDefaults": "デフォルトに戻す", diff --git a/locales/ko/common.json b/locales/ko/common.json index c0c667bcf..bd7adc88a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "참여하기", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "", "custom.colors.bg": "", "custom.colors.bg-darker": "", diff --git a/locales/ko/tier-list-maker.json b/locales/ko/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/ko/tier-list-maker.json +++ b/locales/ko/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 87919d3fb..206a03945 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -136,6 +136,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "", "team.mapWins": "", "team.seed": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index dac5ccd12..b862f89c8 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -186,6 +186,23 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index dff60ef00..d45598651 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "", "custom.colors.bg": "", "custom.colors.bg-darker": "", diff --git a/locales/nl/tier-list-maker.json b/locales/nl/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/nl/tier-list-maker.json +++ b/locales/nl/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 763e46d1c..71b5963a0 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -140,6 +140,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "", "team.mapWins": "", "team.seed": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index ba2709109..974ecd494 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -186,6 +186,27 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 531f845e6..b92241893 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Dołącz", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Niestandardowe kolory", "custom.colors.bg": "Tło", "custom.colors.bg-darker": "Ciemniejsze tło", diff --git a/locales/pl/tier-list-maker.json b/locales/pl/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/pl/tier-list-maker.json +++ b/locales/pl/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index fe45d19ab..ef79292d9 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -144,6 +144,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "", "team.mapWins": "", "team.seed": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 765dc91bc..8fa45d203 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -186,6 +186,31 @@ "seasons.noReportedWeapons": "", "seasons.clickARow": "", "seasons.loading": "", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_few": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_few": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 3b3a84aab..5ab4b754b 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -149,6 +149,16 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Entrar", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Deixa pra lá...", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Cores personalizadas", "custom.colors.bg": "Plano de fundo", "custom.colors.bg-darker": "Plano de fundo mais escuro", diff --git a/locales/pt-BR/tier-list-maker.json b/locales/pt-BR/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/pt-BR/tier-list-maker.json +++ b/locales/pt-BR/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 5a1f53909..6e6cb3274 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -142,6 +142,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "Nenhuma transmissão desse evento está disponível no momento", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Vitórias de set", "team.mapWins": "Vitórias em mapa", "team.seed": "Semente", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index feda07d9c..d09c981c4 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -186,6 +186,29 @@ "seasons.noReportedWeapons": "As armas ainda não foram declaradas", "seasons.clickARow": "Clique em uma fileira pra ver as estatísticas de uso da arma", "seasons.loading": "Carregando...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "", "builds.sorting.header": "", "builds.sorting.backToDefaults": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index f99270bc3..d4891bcd4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -149,6 +149,16 @@ "actions.outlined": "Обводка", "actions.noOutline": "Без обводки", "actions.join": "Присоединиться", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "", "seed": "", "actions.nevermind": "Отмена", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "Лог-ин через Discord бот", "support.perk.useBotToLogIn.extra": "Возможность запрость лог-ин ссылку от бота Lohi как альтернатива обычному лог-ину", "support.perk.earlyAccess": "", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "Пользовательские цвета", "custom.colors.bg": "Фон", "custom.colors.bg-darker": "Фон темнее", diff --git a/locales/ru/tier-list-maker.json b/locales/ru/tier-list-maker.json index f8d8ab98b..d869c964f 100644 --- a/locales/ru/tier-list-maker.json +++ b/locales/ru/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "", - "download": "", "mainWeapons": "", "subWeapons": "", "specialWeapons": "", @@ -18,7 +17,8 @@ "resetConfirmation": "", "noDuplicates": "", "showTierHeaders": "", - "titlePlaceholder": "", + "title": "", + "showUsername": "", "by": "", "custom": "" } diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index caa3438af..447a01c45 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -144,6 +144,12 @@ "finalize.receivingTeam.label": "", "finalize.receivingTeam.placeholder": "", "streams.none": "На данный момент нет трансляций этого турнира", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "Победы в раундах", "team.mapWins": "Победы в матчах", "team.seed": "Семя", diff --git a/locales/ru/user.json b/locales/ru/user.json index f07ae214c..cb5d0140d 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -186,6 +186,31 @@ "seasons.noReportedWeapons": "Нет записанного оружия", "seasons.clickARow": "Нажмите на ряд, чтобы посмотреть на статистику использованного оружия.", "seasons.loading": "Загрузка...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.count.sets_one": "", + "seasons.summary.count.sets_few": "", + "seasons.summary.count.sets_many": "", + "seasons.summary.count.sets_other": "", + "seasons.summary.count.maps_one": "", + "seasons.summary.count.maps_few": "", + "seasons.summary.count.maps_many": "", + "seasons.summary.count.maps_other": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "Изменить сортировку", "builds.sorting.header": "Изменить сортировку сборок", "builds.sorting.backToDefaults": "По умолчанию", diff --git a/locales/zh/common.json b/locales/zh/common.json index 701fd99f9..5407ef986 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -149,6 +149,16 @@ "actions.outlined": "描边", "actions.noOutline": "无描边", "actions.join": "加入", + "imageExport.export": "", + "imageExport.download": "", + "imageExport.theme.light": "", + "imageExport.theme.dark": "", + "imageExport.theme.lightCustom": "", + "imageExport.theme.darkCustom": "", + "imageExport.qrCode": "", + "imageExport.buildTitle": "", + "imageExport.abilityPoints": "", + "imageExport.abilityChunks": "", "host": "房主", "seed": "{{number}} 号种子", "actions.nevermind": "反悔", @@ -287,6 +297,8 @@ "support.perk.useBotToLogIn": "通过 Discord 机器人登录", "support.perk.useBotToLogIn.extra": "向 Lohi 机器人请求一个登录链接,以此作为网站常规登录方式的备选方案", "support.perk.earlyAccess": "部分新功能的优先体验权", + "support.perk.seasonSummaryImage": "", + "support.perk.seasonSummaryImage.extra": "", "custom.colors.title": "自定义颜色", "custom.colors.bg": "背景", "custom.colors.bg-darker": "更暗背景", diff --git a/locales/zh/tier-list-maker.json b/locales/zh/tier-list-maker.json index 405ae0a64..36577dc4e 100644 --- a/locales/zh/tier-list-maker.json +++ b/locales/zh/tier-list-maker.json @@ -1,6 +1,5 @@ { "addTier": "添加分级", - "download": "下载图片 (.png)", "mainWeapons": "主要武器", "subWeapons": "次要武器", "specialWeapons": "特殊武器", @@ -18,7 +17,8 @@ "resetConfirmation": "您确定要重置强度榜吗?这将移除所有项目并恢复默认分级。", "noDuplicates": "", "showTierHeaders": "显示分级标题", - "titlePlaceholder": "点击添加标题...", + "title": "", + "showUsername": "", "by": "制作者: ", "custom": "自定义" } diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 32caa1c54..6aba89afa 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -138,6 +138,12 @@ "finalize.receivingTeam.label": "接收队伍", "finalize.receivingTeam.placeholder": "选择队伍...", "streams.none": "目前暂无此赛事的实时直播", + "run.sets": "", + "run.maps": "", + "run.qualifiedFor": "", + "run.firstTitle": "", + "run.latestTitle": "", + "run.seriesTitles": "", "team.setWins": "胜利轮数", "team.mapWins": "胜利局数", "team.seed": "种子", diff --git a/locales/zh/user.json b/locales/zh/user.json index 0cf5b0a32..ec841dc0b 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -186,6 +186,23 @@ "seasons.noReportedWeapons": "暂无汇报的武器", "seasons.clickARow": "点击一行以查看武器使用数据", "seasons.loading": "加载中...", + "seasons.summary.sets": "", + "seasons.summary.maps": "", + "seasons.summary.winStreak": "", + "seasons.summary.soloRank": "", + "seasons.summary.teamRank": "", + "seasons.summary.activity": "", + "seasons.summary.activity.tournament": "", + "seasons.summary.activity.both": "", + "seasons.summary.bestStage": "", + "seasons.summary.topWeapons": "", + "seasons.summary.topMates": "", + "seasons.summary.clutch": "", + "seasons.summary.bestWins": "", + "seasons.summary.bestTournament": "", + "seasons.summary.opponentSp": "", + "seasons.summary.export": "", + "seasons.summary.export.supporterPerk": "", "builds.sorting.changeButton": "更改排序方式", "builds.sorting.header": "更改配装排序方式", "builds.sorting.backToDefaults": "恢复默认", diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 3cb73546e..90338ae71 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -369,6 +369,9 @@ export function buildCases(fx: Fixtures): { add("SkillRepository.findSeasonProgressionByUserId", fx.sq, (sq) => SkillRepository.findSeasonProgressionByUserId(sq), ); + add("SkillRepository.findSeasonActiveDaysByUserId", fx.sq, (sq) => + SkillRepository.findSeasonActiveDaysByUserId(sq), + ); // NotificationRepository add("NotificationRepository.findByUserId", fx.notification, (notification) => @@ -480,6 +483,15 @@ export function buildCases(fx: Fixtures): { type: "MATE", }), ); + add("PlayerStatRepository.findSeasonSetScoresByUserId", fx.sq, (sq) => + PlayerStatRepository.findSeasonSetScoresByUserId(sq), + ); + add("PlayerStatRepository.findSeasonBestSetsByUserId", fx.sq, (sq) => + PlayerStatRepository.findSeasonBestSetsByUserId({ ...sq, limit: 3 }), + ); + add("PlayerStatRepository.findSeasonTournamentRunsByUserId", fx.sq, (sq) => + PlayerStatRepository.findSeasonTournamentRunsByUserId(sq), + ); // ReportedWeaponRepository add( @@ -979,6 +991,9 @@ export function buildCases(fx: Fixtures): { add("UserRepository.findIdByIdentifier", fx.heavyUser, (user) => UserRepository.findIdByIdentifier(user.identifier), ); + add("UserRepository.findCountriesByUserIds", fx.skillBatch, (skillBatch) => + UserRepository.findCountriesByUserIds(skillBatch.userIds), + ); add("UserRepository.findBuildFieldsByIdentifier", fx.heavyUser, (user) => UserRepository.findBuildFieldsByIdentifier(user.identifier), ); diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index 983db3ac7..c39e82aad 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -557,7 +557,7 @@ async function resolveCalendarAuthorId() { async function resolveCalendarWindow() { const row = await db .selectFrom("CalendarEventDate") - .select(({ fn }) => fn.max("startTime").as("maxStartTime")) + .select(({ fn }) => fn.max("startsAt").as("maxStartTime")) .executeTakeFirst(); if (typeof row?.maxStartTime !== "number") return null; @@ -569,7 +569,7 @@ async function resolveCalendarWindow() { async function resolveScrimWindow() { const row = await db .selectFrom("ScrimPost") - .select(({ fn }) => fn.max("at").as("maxAt")) + .select(({ fn }) => fn.max("startsAt").as("maxAt")) .executeTakeFirst(); if (typeof row?.maxAt !== "number") return null;