diff --git a/src/components/Card.astro b/src/components/Card.astro index 15fc92a..887d232 100644 --- a/src/components/Card.astro +++ b/src/components/Card.astro @@ -82,7 +82,7 @@ const isStormgatenexus = data.source == "news" && data.author_url?.includes("sto ) }
-
+
{ data.source === "reddit" ? ( diff --git a/src/components/sources/NewsMeta.astro b/src/components/sources/NewsMeta.astro index a3fc013..26e1c39 100644 --- a/src/components/sources/NewsMeta.astro +++ b/src/components/sources/NewsMeta.astro @@ -1,4 +1,5 @@ --- +import Svg from "@jasikpark/astro-svg-loader" import type { Content } from "../../lib/content" export interface Props { @@ -6,7 +7,7 @@ export interface Props { } const { data } = Astro.props -const isStormgatenexus = data.source == "news" && data.author_url.includes("stormgatenexus.com") +const isStormgatenexus = data.source == "news" && data.author_url?.includes("stormgatenexus.com") function prefixUrl(url: string) { if (url.startsWith("http")) { return url @@ -24,7 +25,7 @@ function prefixUrl(url: string) { ) : ( <> - + {data.author_url ? ( diff --git a/src/components/widgets/FactionDropdown.tsx b/src/components/widgets/FactionDropdown.tsx new file mode 100644 index 0000000..81c0c79 --- /dev/null +++ b/src/components/widgets/FactionDropdown.tsx @@ -0,0 +1,31 @@ +import { createEffect, createSignal, onMount } from "solid-js" +import { SelectButton, type SelectButtonOption } from "../ui/SelectButton" +import infernals from "../../assets/game/factions/infernals-small-glow.png" +import vanguard from "../../assets/game/factions/vanguard-small-glow.png" +import { Race } from "../../lib/api" + +const factionOptions: SelectButtonOption[] = [ + { + label: "Infernals", + value: Race.INFERNALS, + icon: infernals.src, + }, + { label: "Vanguard", value: Race.VANGUARD, icon: vanguard.src }, +] + +export function FactionDropdown(props: { queryParam: string; selected: (typeof factionOptions)[number]["value"] }) { + const selected = props.selected ?? "all" + const [faction, setFaction] = createSignal( + factionOptions.find((option) => option.value === selected) || factionOptions[0] + ) + + createEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + if (faction()?.value === selected) return + if (faction()?.value && faction()?.value != "all") urlParams.set(props.queryParam, faction()?.value!) + else urlParams.delete(props.queryParam) + window.location.href = `${window.location.pathname}${urlParams.size ? "?" + urlParams.toString() : ""}` + }) + + return +} diff --git a/src/components/widgets/GameLengthChart.tsx b/src/components/widgets/GameLengthChart.tsx index 2b2aac3..230bb8b 100644 --- a/src/components/widgets/GameLengthChart.tsx +++ b/src/components/widgets/GameLengthChart.tsx @@ -19,7 +19,8 @@ const prettyLabels = { "841-960": "14-16m", "961-1080": "16-18m", "1081-1200": "18-20m", - "1200+": "20m+", + "1201-1320": "20-22m", + "1320+": "22m+", } as const export function GameLengthChart(props: GameLengthProps) { @@ -55,6 +56,7 @@ export function GameLengthChart(props: GameLengthProps) { intersect: false, mode: "index", }, + maxBarThickness: 50, plugins: { title: { display: false, diff --git a/src/components/widgets/Leaderboard.tsx b/src/components/widgets/Leaderboard.tsx index 9a299af..0cadabb 100644 --- a/src/components/widgets/Leaderboard.tsx +++ b/src/components/widgets/Leaderboard.tsx @@ -43,7 +43,7 @@ export function Leaderboard(props: Props) { const [query, setQuery] = createSignal(props.query || undefined) const [page, setPage] = createSignal(props.page || 1) const [mode, setMode] = createSignal(props.mode ?? "ranked_1v1") - const [order, setOrder] = createSignal(props.order ?? undefined) + const [order, setOrder] = createSignal(props.order ?? LeaderboardOrder.POINTS) const [faction, setFaction] = createSignal(props.faction ?? undefined) const [isPending, start] = useTransition() const [isBrowserNavigation, setIsBrowserNavigation] = createSignal(true) @@ -164,11 +164,11 @@ export function Leaderboard(props: Props) { class={classes(styles.button.sm, "flex-auto bg-transparent text-white outline-none")} placeholder="Search" ref={searchInput} - onKeyDown={(e) => e.key === "Enter" && setQuery(searchInput?.value)} + onKeyDown={(e) => e.key === "Enter" && setQuery(searchInput?.value.trim() || undefined)} /> diff --git a/src/components/widgets/MmrHistoryChart.tsx b/src/components/widgets/MmrHistoryChart.tsx new file mode 100644 index 0000000..168413c --- /dev/null +++ b/src/components/widgets/MmrHistoryChart.tsx @@ -0,0 +1,70 @@ +// @ts-nocheck + +import { onMount } from "solid-js" +import { Chart, Title, Tooltip, Colors, TimeScale, type ChartOptions, type ChartData } from "chart.js" +import { Line } from "solid-chartjs" +import "chartjs-adapter-date-fns" + +type MmrHistoryChartProps = { + labels: string[] + data: number[] +} + +const longNumberFormatter = new Intl.NumberFormat("en-us") + +export function MmrHistoryChart(props: MmrHistoryChartProps) { + let canvas: HTMLCanvasElement + onMount(() => { + Chart.register(Title, Tooltip, Colors, TimeScale) + }) + + const chartData: ChartData = { + labels: props.labels, + datasets: [ + { + label: "MMR", + data: props.data, + tension: 0.3, + borderColor: "#9E9E9E", + pointRadius: 5, + pointHoverRadius: 2, + pointBorderColor: "transparent", + }, + ], + } + + const chartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: true, + interaction: { + intersect: false, + mode: "index", + }, + scales: { + y: { + ticks: { + precision: 0, + }, + offset: true, + }, + x: { + type: "time", + time: { unit: "day", tooltipFormat: "d MMM, y" }, + }, + }, + plugins: { + tooltip: { + displayColors: false, + callbacks: { + label: (c) => `MMR: ${Math.round(c.parsed.y)}`, + }, + }, + }, + } + + return ( +
+ (canvas = c)} /> +
+ ) +} diff --git a/src/layouts/PlayerLayout.astro b/src/layouts/PlayerLayout.astro index 752dd8d..b21072f 100644 --- a/src/layouts/PlayerLayout.astro +++ b/src/layouts/PlayerLayout.astro @@ -44,6 +44,7 @@ const playerInfo = (await getCollection("players")).find((p) => p.data.playerId current={[ { href: `/players/${slug}`, label: "Overview" }, { href: `/players/${slug}/matches`, label: "Match History" }, + { href: `/players/${slug}/statistics`, label: "Statistics" }, ]} /> diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 160a6e9..28f4604 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -11,11 +11,12 @@ export type { ActivityStatistics } from "./models/ActivityStatistics" export type { ActivityStatisticsActivity } from "./models/ActivityStatisticsActivity" export type { ActivityStatisticsEntry } from "./models/ActivityStatisticsEntry" export type { ActivityStatisticsServerEntry } from "./models/ActivityStatisticsServerEntry" +export { Aggregation } from "./models/Aggregation" export type { ErrorResponse } from "./models/ErrorResponse" export { Leaderboard } from "./models/Leaderboard" export type { LeaderboardDumpResponse } from "./models/LeaderboardDumpResponse" export type { LeaderboardEntryHistory } from "./models/LeaderboardEntryHistory" -export type { LeaderboardEntryHistoryEntry } from "./models/LeaderboardEntryHistoryEntry" +export type { LeaderboardEntryHistoryRow } from "./models/LeaderboardEntryHistoryRow" export type { LeaderboardEntryResponse } from "./models/LeaderboardEntryResponse" export { LeaderboardOrder } from "./models/LeaderboardOrder" export type { LeaderboardResponse } from "./models/LeaderboardResponse" @@ -35,13 +36,13 @@ export type { PlayerMatchupsStatsEntry } from "./models/PlayerMatchupsStatsEntry export type { PlayerMatchupsStatsMatchup } from "./models/PlayerMatchupsStatsMatchup" export type { PlayerOpponentsStats } from "./models/PlayerOpponentsStats" export type { PlayerOpponentsStatsOpponent } from "./models/PlayerOpponentsStatsOpponent" -export type { PlayerPreferences } from "./models/PlayerPreferences" export type { PlayerResponse } from "./models/PlayerResponse" export type { PlayerStatsEntry } from "./models/PlayerStatsEntry" export type { PlayerStatsEntryAggregated } from "./models/PlayerStatsEntryAggregated" export type { PlayerStatsEntryNumBreakdown } from "./models/PlayerStatsEntryNumBreakdown" export { ProfilePrivacy } from "./models/ProfilePrivacy" export { Race } from "./models/Race" +export { Resolution } from "./models/Resolution" export type { StatsByTime } from "./models/StatsByTime" export type { StatsByTimeEntry } from "./models/StatsByTimeEntry" export type { StatsByTimeHistoryPoint } from "./models/StatsByTimeHistoryPoint" diff --git a/src/lib/api/models/PlayerPreferences.ts b/src/lib/api/models/Aggregation.ts similarity index 50% rename from src/lib/api/models/PlayerPreferences.ts rename to src/lib/api/models/Aggregation.ts index 84ead43..1ab7c5d 100644 --- a/src/lib/api/models/PlayerPreferences.ts +++ b/src/lib/api/models/Aggregation.ts @@ -2,7 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ProfilePrivacy } from "./ProfilePrivacy" -export type PlayerPreferences = { - privacy_profile?: ProfilePrivacy | null +export enum Aggregation { + LAST = "last", + MAX_MMR = "max_mmr", + MAX_POINTS = "max_points", } diff --git a/src/lib/api/models/LeaderboardEntryHistory.ts b/src/lib/api/models/LeaderboardEntryHistory.ts index ca8c7bf..9113eac 100644 --- a/src/lib/api/models/LeaderboardEntryHistory.ts +++ b/src/lib/api/models/LeaderboardEntryHistory.ts @@ -2,7 +2,12 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { LeaderboardEntryHistoryEntry } from "./LeaderboardEntryHistoryEntry" +import type { Aggregation } from "./Aggregation" +import type { LeaderboardEntryHistoryRow } from "./LeaderboardEntryHistoryRow" +import type { Resolution } from "./Resolution" export type LeaderboardEntryHistory = { - history: Array + cached_at: string + resolution: Resolution + aggregation: Aggregation + history: Array } diff --git a/src/lib/api/models/LeaderboardEntryHistoryEntry.ts b/src/lib/api/models/LeaderboardEntryHistoryRow.ts similarity index 64% rename from src/lib/api/models/LeaderboardEntryHistoryEntry.ts rename to src/lib/api/models/LeaderboardEntryHistoryRow.ts index 4c76d12..85192e0 100644 --- a/src/lib/api/models/LeaderboardEntryHistoryEntry.ts +++ b/src/lib/api/models/LeaderboardEntryHistoryRow.ts @@ -2,9 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type LeaderboardEntryHistoryEntry = { +export type LeaderboardEntryHistoryRow = { time: string - mmr: number - max_confirmed_mmr?: number | null + mmr?: number | null points?: number | null } diff --git a/src/lib/api/models/Race.ts b/src/lib/api/models/Race.ts index 14a9e77..3dc3f6a 100644 --- a/src/lib/api/models/Race.ts +++ b/src/lib/api/models/Race.ts @@ -5,5 +5,4 @@ export enum Race { INFERNALS = "infernals", VANGUARD = "vanguard", - RANDOM = "random", } diff --git a/src/lib/api/models/Resolution.ts b/src/lib/api/models/Resolution.ts new file mode 100644 index 0000000..580dd6d --- /dev/null +++ b/src/lib/api/models/Resolution.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export enum Resolution { + MINUTE = "minute", + HOUR = "hour", + DAY = "day", + WEEK = "week", +} diff --git a/src/lib/api/services/LeaderboardEntriesApi.ts b/src/lib/api/services/LeaderboardEntriesApi.ts index 3b0e4b9..0a7dcd6 100644 --- a/src/lib/api/services/LeaderboardEntriesApi.ts +++ b/src/lib/api/services/LeaderboardEntriesApi.ts @@ -2,7 +2,9 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Aggregation } from "../models/Aggregation" import type { LeaderboardEntryHistory } from "../models/LeaderboardEntryHistory" +import type { Resolution } from "../models/Resolution" import type { CancelablePromise } from "../core/CancelablePromise" import { OpenAPI } from "../core/OpenAPI" import { request as __request } from "../core/request" @@ -13,11 +15,15 @@ export class LeaderboardEntriesApi { */ public static getLeaderboardEntryHistory({ leaderboardEntryId, + resolution, + aggregation, }: { /** * Player Leaderboard Entry ID */ leaderboardEntryId: string + resolution?: Resolution | null + aggregation?: Aggregation | null }): CancelablePromise { return __request(OpenAPI, { method: "GET", @@ -25,6 +31,10 @@ export class LeaderboardEntriesApi { path: { leaderboard_entry_id: leaderboardEntryId, }, + query: { + resolution: resolution, + aggregation: aggregation, + }, errors: { 404: `Player leaderboard entry was not found`, 500: `Server error`, diff --git a/src/lib/api/services/PlayersApi.ts b/src/lib/api/services/PlayersApi.ts index a3e5596..f177161 100644 --- a/src/lib/api/services/PlayersApi.ts +++ b/src/lib/api/services/PlayersApi.ts @@ -7,7 +7,6 @@ import type { PlayerActivityStats } from "../models/PlayerActivityStats" import type { PlayerMatchesResponse } from "../models/PlayerMatchesResponse" import type { PlayerMatchupsStats } from "../models/PlayerMatchupsStats" import type { PlayerOpponentsStats } from "../models/PlayerOpponentsStats" -import type { PlayerPreferences } from "../models/PlayerPreferences" import type { PlayerResponse } from "../models/PlayerResponse" import type { Race } from "../models/Race" import type { CancelablePromise } from "../core/CancelablePromise" @@ -45,6 +44,7 @@ export class PlayersApi { public static getPlayerMatches({ playerId, race, + opponentPlayerId, page, count, }: { @@ -53,6 +53,7 @@ export class PlayersApi { */ playerId: string race?: Race | null + opponentPlayerId?: string | null page?: number | null count?: number | null }): CancelablePromise { @@ -64,6 +65,7 @@ export class PlayersApi { }, query: { race: race, + opponent_player_id: opponentPlayerId, page: page, count: count, }, @@ -97,58 +99,6 @@ export class PlayersApi { }, }) } - /** - * @returns PlayerPreferences Player found successfully - * @throws ApiError - */ - public static getPlayerPreferences({ - playerId, - }: { - /** - * Player ID - */ - playerId: string - }): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/v0/players/{player_id}/preferences", - path: { - player_id: playerId, - }, - errors: { - 404: `Player was not found`, - 500: `Server error`, - }, - }) - } - /** - * @returns PlayerPreferences Player preferences updated successfully - * @throws ApiError - */ - public static updatePlayerPreferences({ - playerId, - requestBody, - }: { - /** - * Player ID - */ - playerId: string - requestBody: PlayerPreferences - }): CancelablePromise { - return __request(OpenAPI, { - method: "PUT", - url: "/v0/players/{player_id}/preferences", - path: { - player_id: playerId, - }, - body: requestBody, - mediaType: "application/json", - errors: { - 404: `Player was not found`, - 500: `Server error`, - }, - }) - } /** * @returns PlayerActivityStats Player found successfully * @throws ApiError diff --git a/src/lib/labels.ts b/src/lib/labels.ts index 387b9e2..d1b904e 100644 --- a/src/lib/labels.ts +++ b/src/lib/labels.ts @@ -3,7 +3,6 @@ import { League, Race } from "./api" export const raceLabels: Record = { infernals: "Infernals", vanguard: "Vanguard", - random: "Random", } export const leagueLabels: Record = { diff --git a/src/pages/index.astro b/src/pages/index.astro index 80f95e8..3eabb93 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -82,7 +82,7 @@ const news = (await newsRequest).sort((a, b) => { {data.description?.length ? (

) : null} -

+
{data.source === "reddit" ? : } {data.published_at && ( diff --git a/src/pages/players/[id]-[username]/matches.astro b/src/pages/players/[id]-[username]/matches.astro index 9875209..8838063 100644 --- a/src/pages/players/[id]-[username]/matches.astro +++ b/src/pages/players/[id]-[username]/matches.astro @@ -4,27 +4,13 @@ import PlayerLayout from "../../../layouts/PlayerLayout.astro" import { MatchHistory } from "../../../components/widgets/MatchHistory.tsx" import { PlayersApi, Race } from "../../../lib/api" import Section from "../../../components/layout/Section.astro" +import { getDataOrErrorResponse } from "../../../lib/utils" const { page = 1, faction } = Object.fromEntries(new URL(Astro.request.url).searchParams.entries()) -// to be moved to own file -async function getDataOrErrorResponse( - ...values: T -): Promise<[{ -readonly [P in keyof T]: Awaited }, error: Response | null]> { - try { - const result = await Promise.all(values) - return [result, null] - } catch (e) { - return [[] as any, new Response(null, { status: 500, statusText: `${e}` })] - } -} - const playerId = Astro.params.id! -const [[player], error] = await getDataOrErrorResponse( - PlayersApi.getPlayer({ playerId }), - PlayersApi.getPlayerMatches({ playerId }) -) +const [[player], error] = await getDataOrErrorResponse(PlayersApi.getPlayer({ playerId })) if (error) return error --- diff --git a/src/pages/players/[id]-[username]/statistics.astro b/src/pages/players/[id]-[username]/statistics.astro new file mode 100644 index 0000000..34f750d --- /dev/null +++ b/src/pages/players/[id]-[username]/statistics.astro @@ -0,0 +1,121 @@ +--- +export const prerender = false + +import { + Aggregation, + LeaderboardEntriesApi, + LeaderboardEntryHistoryRow, + PlayersApi, + Resolution, +} from "../../../lib/api" +import Section from "../../../components/layout/Section.astro" +import Main from "../../../components/layout/Main.astro" +import { MmrHistoryChart } from "../../../components/widgets/MmrHistoryChart" +import Widget from "../../../components/Widget.astro" +import { FactionDropdown } from "../../../components/widgets/FactionDropdown" +import { getDataOrErrorResponse } from "../../../lib/utils" +import Sidebar from "../../../components/layout/Sidebar.astro" +import PlayerActivity from "../../../components/widgets/PlayerActivity.astro" +import PlayerMatchupStats from "../../../components/widgets/PlayerMatchupStats.astro" +import PlayerGameLengthStats from "../../../components/widgets/PlayerGameLengthStats.astro" +import PlayerOpponents from "../../../components/widgets/PlayerOpponents.astro" +import PlayerLayout from "../../../layouts/PlayerLayout.astro" +import { GameLengthChart } from "../../../components/widgets/GameLengthChart" +import { Image } from "astro:assets" +import infernals from "../../../assets/game/factions/infernals-small.png" +import vanguard from "../../../assets/game/factions/vanguard-small.png" +import { raceLabels } from "../../../lib/labels" + +const playerId = Astro.params.id! + +const [[player, playerActivity, playerMatchupStats, playerOpponents], error] = await getDataOrErrorResponse( + PlayersApi.getPlayer({ playerId }), + PlayersApi.getPlayerStatisticsActivity({ playerId }), + PlayersApi.getPlayerStatisticsMatchups({ playerId }), + PlayersApi.getPlayerStatisticsOpponents({ playerId, count: 10 }) +) +if (error) return error + +const leaderboardEntries = player?.leaderboard_entries + .filter((x) => x.matches > 0) + .sort((a, b) => (b.points ?? 0) - (a.points ?? 0)) + +const faction = new URLSearchParams(Astro.url.search).get("faction") || leaderboardEntries?.[0].race +const leaderboard = leaderboardEntries.find((leaderboard) => leaderboard.race === faction) || leaderboardEntries?.[0] + +let leaderboardHistory: LeaderboardEntryHistoryRow[] = [] + +if (leaderboard?.leaderboard_entry_id) { + const [[leaderboardEntryHistory], error2] = await getDataOrErrorResponse( + LeaderboardEntriesApi.getLeaderboardEntryHistory({ + leaderboardEntryId: leaderboard?.leaderboard_entry_id, + resolution: Resolution.DAY, + aggregation: Aggregation.LAST, + }) + ) + leaderboardHistory = leaderboardEntryHistory.history + if (error2) return error +} + +const mmrEntries: number[] = [] +const dateLabels: string[] = [] +for (const entry of leaderboardHistory) { + if (entry.mmr) { + dateLabels.push(entry.time) + mmrEntries.push(Math.round(entry.mmr)) + } +} + +const today = new Date().toISOString().slice(0, 10) +if (dateLabels[dateLabels.length - 1] !== today) { + dateLabels.push(today) + mmrEntries.push(Math.round(leaderboard.mmr)) +} +--- + + +
+
+
+ +
+ { + ( + +
+ {mmrEntries.length > 1 ? ( + + ) : ( +

Not enough data

+ )} +
+
+ ) + } + { + playerMatchupStats.matchups + .filter((m) => m.race == faction) + .map((matchup) => ( + + +
+ {matchup.race} + {raceLabels[matchup.race]} + vs + {matchup.opponent_race} + {raceLabels[matchup.opponent_race]} +
+
+ )) + } +
+ + {playerActivity?.history && } + {playerMatchupStats.matchups.length > 0 && } + +
+