From dd60e13e80278c9b8b0105fe6e621a4b055559ba Mon Sep 17 00:00:00 2001 From: Trevor Cates <107319355+Emissaryx@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:20:46 -0500 Subject: [PATCH 01/97] Build scenario analytics dashboard --- .github/workflows/deploy-cloudflare-pages.yml | 35 ++ package.json | 1 + src/components/WarIcon.tsx | 13 +- src/components/scenario/ScenarioFilters.tsx | 47 +- src/components/scenario/ScenarioList.tsx | 158 ++++- src/components/scenario/ScenarioListTable.tsx | 276 +++++---- .../scenario/ScenarioRosterPreview.tsx | 182 ++++++ src/components/scenario/ScenarioStandouts.tsx | 545 ++++++++++++++++++ src/components/scenario/scenarioRoles.ts | 44 ++ src/i18n/en/components.json | 14 + src/index.tsx | 2 +- src/pages/Home.tsx | 4 +- src/react-app-env.d.ts | 2 + src/style.scss | 456 +++++++++++++++ src/utils.ts | 53 +- vite.config.ts | 7 +- 16 files changed, 1639 insertions(+), 200 deletions(-) create mode 100644 .github/workflows/deploy-cloudflare-pages.yml create mode 100644 src/components/scenario/ScenarioRosterPreview.tsx create mode 100644 src/components/scenario/ScenarioStandouts.tsx create mode 100644 src/components/scenario/scenarioRoles.ts diff --git a/.github/workflows/deploy-cloudflare-pages.yml b/.github/workflows/deploy-cloudflare-pages.yml new file mode 100644 index 00000000..c4bfcca3 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-pages.yml @@ -0,0 +1,35 @@ +name: Deploy Cloudflare Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build site + run: npm run build + + - name: Deploy to Cloudflare Pages + run: npx wrangler@4 pages deploy build --project-name=ror-killboard-preview + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/package.json b/package.json index 074c4c7e..bd54a6d9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "vite": "^8.0.14" }, "scripts": { + "dev": "vite", "start": "vite", "build": "vite build", "test": "tsc -p . && vite build && oxlint --type-aware src", diff --git a/src/components/WarIcon.tsx b/src/components/WarIcon.tsx index dd989c88..ec207ef7 100644 --- a/src/components/WarIcon.tsx +++ b/src/components/WarIcon.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react'; +import { assetUrl } from '@/utils'; export const WarIcon = ({ icon, @@ -19,18 +20,18 @@ export const WarIcon = ({ switch (frameType) { case 'circle': { return selected - ? '/images/icons/round_frame_press.png' - : '/images/icons/round_frame.png'; + ? assetUrl('/images/icons/round_frame_press.png') + : assetUrl('/images/icons/round_frame.png'); } case 'hex': { return selected - ? '/images/icons/hex_frame_press.png' - : '/images/icons/hex_frame.png'; + ? assetUrl('/images/icons/hex_frame_press.png') + : assetUrl('/images/icons/hex_frame.png'); } default: { return selected - ? '/images/icons/square_frame_press.png' - : '/images/icons/square_frame.png'; + ? assetUrl('/images/icons/square_frame_press.png') + : assetUrl('/images/icons/square_frame.png'); } } })(); diff --git a/src/components/scenario/ScenarioFilters.tsx b/src/components/scenario/ScenarioFilters.tsx index aae068ac..b045d25f 100644 --- a/src/components/scenario/ScenarioFilters.tsx +++ b/src/components/scenario/ScenarioFilters.tsx @@ -10,16 +10,24 @@ const getQueueTypeFilters = ( const premadeOnly = search.get('premadeOnly') === 'true'; switch (queueType) { - case 'standard': + case 'standard': { return { queueType: 0, premadeOnly }; - case 'group_ranked': + } + case 'group_ranked': { return { queueType: 1, premadeOnly }; - case 'solo': + } + case 'solo': { return { queueType: 2, premadeOnly }; - case 'city_siege': - return { queueType: 3, premadeOnly }; - case 'solo_ranked': + } + case 'city_siege': { return { queueType: 4, premadeOnly }; + } + case 'solo_ranked': { + return { queueType: 5, premadeOnly }; + } + case 'group_challenge': { + return { queueType: 6, premadeOnly }; + } } return { premadeOnly }; @@ -63,10 +71,18 @@ export const getScenarioFilters = ( } const scoreboardEntry: Record = {}; - if (characterId) scoreboardEntry.characterId = { eq: characterId }; - if (guildId) scoreboardEntry.guildId = { eq: guildId }; - if (wins !== undefined) scoreboardEntry.isWinner = { eq: wins }; - if (premadeOnly) scoreboardEntry.isGuildPremade = { eq: true }; + if (characterId) { + scoreboardEntry.characterId = { eq: characterId }; + } + if (guildId) { + scoreboardEntry.guildId = { eq: guildId }; + } + if (wins !== undefined) { + scoreboardEntry.isWinner = { eq: wins }; + } + if (premadeOnly) { + scoreboardEntry.isGuildPremade = { eq: true }; + } if (Object.keys(scoreboardEntry).length > 0) { where.scoreboardEntries = { some: scoreboardEntry }; @@ -114,7 +130,7 @@ export const ScenarioFilters = ({ {t('scenarioFilters.queueTypeStandard')} + @@ -152,9 +171,9 @@ export const ScenarioFilters = ({ - - - + + + diff --git a/src/components/scenario/ScenarioList.tsx b/src/components/scenario/ScenarioList.tsx index 608ef41d..c5301b36 100644 --- a/src/components/scenario/ScenarioList.tsx +++ b/src/components/scenario/ScenarioList.tsx @@ -2,11 +2,14 @@ import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router'; +import { format } from 'date-fns'; +import { useState } from 'react'; import type { Query } from '@/__generated__/graphql'; import { ErrorMessage } from '@/components/global/ErrorMessage'; import { getScenarioFilters } from '@/components/scenario/ScenarioFilters'; import { ScenarioListTable } from '@/components/scenario/ScenarioListTable'; import { QueryPagination } from '@/components/global/QueryPagination'; +import { ScenarioStandouts } from '@/components/scenario/ScenarioStandouts'; const SCENARIO_LIST = gql` query GetScenarioList( @@ -36,6 +39,31 @@ const SCENARIO_LIST = gql` points wasSurrender tier + queueType + numPlayers + numDeaths + scoreboardEntries { + character { + id + name + career + } + guild { + id + name + } + team + level + renownRank + kills + deathBlows + deaths + damage + killDamage + healing + protection + objectiveScore + } } pageInfo { hasNextPage @@ -50,18 +78,22 @@ const SCENARIO_LIST = gql` export const ScenarioList = ({ characterId, guildId, + loadMore = false, perPage = 15, }: { characterId?: string; guildId?: string; + loadMore?: boolean; perPage?: number; }): React.ReactElement | null => { const { t } = useTranslation(['common', 'components']); const [search] = useSearchParams(); + const [resultLimit, setResultLimit] = useState(perPage); + const [loadingMore, setLoadingMore] = useState(false); const { loading, error, data, refetch } = useQuery(SCENARIO_LIST, { variables: { - first: perPage, + first: loadMore ? resultLimit : perPage, where: getScenarioFilters(search, { characterId, guildId }), }, }); @@ -77,15 +109,127 @@ export const ScenarioList = ({ } const pageInfo = data?.scenarios?.pageInfo; + const scenarios = data.scenarios.nodes; + if (scenarios.length === 0) { + return ; + } + const scenarioDates = scenarios.flatMap((scenario) => [ + new Date(scenario.startTime), + new Date(scenario.endTime), + ]); + const earliestScenarioDate = new Date( + Math.min(...scenarioDates.map((date) => date.getTime())), + ); + const latestScenarioDate = new Date( + Math.max(...scenarioDates.map((date) => date.getTime())), + ); + const orderWins = scenarios.filter( + (scenario) => scenario.winner === 0, + ).length; + const destructionWins = scenarios.filter( + (scenario) => scenario.winner === 1, + ).length; + const decidedScenarios = orderWins + destructionWins; + const orderWinPercentage = + decidedScenarios === 0 ? 50 : (orderWins / decidedScenarios) * 100; + const averagePlayers = + scenarios.reduce((total, scenario) => total + scenario.numPlayers, 0) / + scenarios.length; + const averageDurationSeconds = + scenarios.reduce( + (total, scenario) => + total + + (new Date(scenario.endTime).getTime() - + new Date(scenario.startTime).getTime()) / + 1000, + 0, + ) / scenarios.length; return ( <> - - +
+
+ {t('components:scenarioList.recentActivity')} + + {format(earliestScenarioDate, 'MMM d, h:mm a')} –{' '} + {format(latestScenarioDate, 'MMM d, h:mm a')} + +
+