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/public/_redirects b/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/src/App.tsx b/src/App.tsx index 6547e0f6..6cb68df6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './i18n/config'; import React from 'react'; import { Character } from '@/pages/Character'; import { Home } from '@/pages/Home'; +import { MainNav } from '@/components/global/MainNav'; import { Kill } from '@/pages/Kill'; import { Guild } from '@/pages/Guild'; import { Search } from '@/pages/Search'; @@ -31,6 +32,7 @@ import { Storyline } from '@/pages/Storyline'; import { StorylineEntry } from '@/pages/StorylineEntry'; import { StorylineActivity } from '@/pages/StorylineActivity'; import { InstanceStatistics } from '@/pages/InstanceStatistics'; +import { InstanceHub } from '@/pages/InstanceHub'; // Extend the Window interface to include gtag declare global { @@ -54,103 +56,130 @@ const App = () => { usePageViews(); return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } - /> - } - /> - - } /> - } /> - } /> - } - /> - } /> - - } /> - } /> - } /> - - } /> - } /> - } /> - } - /> - } - /> - - } /> - } /> - - } /> - } /> - - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } - /> - - } /> - } /> - - } /> - } /> - } /> - } - /> - - } /> - + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + + } /> + } /> + } + /> + } + /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } + /> + } + /> + } + /> + + } /> + } /> + + } /> + } /> + + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } + /> + + } /> + } /> + } + /> + } + /> + } + /> + + } /> + } /> + } + /> + } + /> + + } /> + + ); }; 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/ZoneHeatmap.tsx b/src/components/ZoneHeatmap.tsx index 8884ce00..e9f7ec64 100644 --- a/src/components/ZoneHeatmap.tsx +++ b/src/components/ZoneHeatmap.tsx @@ -6,7 +6,7 @@ export const ZoneHeatmap = ({ zoneId, max, data, - size = 640, + size, }: { zoneId: string; max: number; diff --git a/src/components/creature/VendorItems.tsx b/src/components/creature/VendorItems.tsx index 167057bb..eef40cb9 100644 --- a/src/components/creature/VendorItems.tsx +++ b/src/components/creature/VendorItems.tsx @@ -1,23 +1,22 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; import { gql } from '@apollo/client'; -import { useQuery } from '@apollo/client/react'; +import { useApolloClient } from '@apollo/client/react'; +import { useEffect, useState } from 'react'; import type { Query } from '@/__generated__/graphql'; import { ErrorMessage } from '@/components/global/ErrorMessage'; -import { QueryPagination } from '@/components/global/QueryPagination'; import { GoldPrice } from '@/components/GoldPrice'; const VENDOR_ITEMS = gql` query GetVendorItemsFromCreature( $creatureId: ID! $first: Int - $last: Int - $before: String $after: String ) { creature(id: $creatureId) { id - vendorItems(first: $first, last: $last, before: $before, after: $after) { + vendorItems(first: $first, after: $after) { + totalCount nodes { count item { @@ -38,93 +37,228 @@ const VENDOR_ITEMS = gql` pageInfo { hasNextPage endCursor - hasPreviousPage - startCursor } } } } `; +type VendorItemsConnectionType = NonNullable['vendorItems']; +type VendorItemNode = NonNullable< + NonNullable['nodes'] +>[number]; + +// The API caps a single page at 50 items, and offers no server-side name +// filter on a creature's vendor list. Some vendors sell 100-200+ items, +// which used to mean clicking "Next" a dozen+ times to browse the whole +// catalog. Instead we page through everything up front (a handful of +// 50-item requests even for the biggest vendors) into local state, then +// filter and scroll client-side. export const VendorItems = ({ creatureId, }: { creatureId: string | undefined; }) => { - const perPage = 10; + const perPage = 50; const { t } = useTranslation(['common', 'components']); - const { loading, error, data, refetch } = useQuery(VENDOR_ITEMS, { - variables: { - creatureId, - first: perPage, - }, - }); + const client = useApolloClient(); + const [search, setSearch] = useState(''); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(); + const [hasDataIssue, setHasDataIssue] = useState(false); + + useEffect(() => { + let cancelled = false; + let totalCount: number | undefined; + const controller = new AbortController(); + + // Cursors from this API are just base64-encoded zero-based offsets + // (e.g. offset 49 -> btoa("49")), which lets us request an arbitrary + // [start, start + count) window directly instead of only being able to + // walk forward page by page. + const encodeOffset = (offset: number): string | undefined => + offset > 0 ? btoa(String(offset - 1)) : undefined; + + // Fetch a window of `count` items starting at `start`. If the window + // comes back null (one malformed row poisons the whole array under + // GraphQL's non-null propagation rules), split it in half and retry + // each half independently. This isolates the exact bad row(s) instead + // of discarding the entire 50-item page they happened to land on. + const fetchRange = async ( + start: number, + count: number, + ): Promise => { + if (count <= 0) { + return []; + } + const result = await client.query({ + context: { fetchOptions: { signal: controller.signal } }, + errorPolicy: 'all', + fetchPolicy: 'cache-first', + query: VENDOR_ITEMS, + variables: { after: encodeOffset(start), creatureId, first: count }, + }); + const connection = result.data?.creature?.vendorItems; + if (!connection) { + return []; + } + if (totalCount === undefined) { + totalCount = connection.totalCount; + if (!cancelled) { + setTotal(connection.totalCount); + } + } + if (connection.nodes) { + return connection.nodes; + } + if (count === 1) { + // Narrowed down to a single unrecoverable row. + if (!cancelled) { + setHasDataIssue(true); + } + return []; + } + const half = Math.ceil(count / 2); + const left = await fetchRange(start, half); + const right = await fetchRange(start + half, count - half); + return [...left, ...right]; + }; + + const loadAll = async (): Promise => { + setLoading(true); + setLoadError(undefined); + setHasDataIssue(false); + setItems([]); + let offset = 0; + const accumulated: VendorItemNode[] = []; + + try { + do { + const nodes = await fetchRange(offset, perPage); + accumulated.push(...nodes); + offset += perPage; + if (!cancelled) { + setItems([...accumulated]); + } + if (totalCount === undefined || offset >= totalCount) { + break; + } + } while (!cancelled); + } catch (caughtError) { + if (!cancelled && !controller.signal.aborted) { + setLoadError( + caughtError instanceof Error + ? caughtError + : new Error('Unable to load vendor items.'), + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void loadAll(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [client, creatureId]); - if (loading) { + if (loading && items.length === 0) { return ; } - if (error) { - return ; + if (loadError) { + return ; } - - const vendorItems = data?.creature?.vendorItems; - - if (vendorItems?.nodes == null) { + if (items.length === 0) { return ; } - if (vendorItems?.nodes == null) { - return null; - } - - const { pageInfo } = vendorItems; + const filtered = search + ? items.filter((vendorItem) => + vendorItem.item.name.toLowerCase().includes(search.toLowerCase()), + ) + : items; return ( <> - - - - - - - - - {vendorItems.nodes.map((vendorItem) => ( - - - + + + ))} + +
{t('components:itemVendors.item')}{t('components:itemVendors.price')}
- -
- Item Icon -
- - {vendorItem.item.name} - - x{vendorItem.count} -
-
- - {vendorItem.requiredItems.map((requiredItem) => ( - +
+ +
+ {loading && ( +

+ Gathering {items.length} of {total || '…'} items… +

+ )} + {hasDataIssue && ( +

+ Some items could not be loaded due to a data issue and are missing + from this list. +

+ )} +
+ + + + + + + + + {filtered.map((vendorItem) => ( + + - - ))} - -
{t('components:itemVendors.item')}{t('components:itemVendors.price')}
+
- Item Icon + Item Icon
- - {requiredItem.item.name} + + {vendorItem.item.name} - x{requiredItem.count} + x{vendorItem.count}
- ))} -
- +
+ + {vendorItem.requiredItems.map((requiredItem) => ( + +
+ Item Icon +
+ + {requiredItem.item.name} + + x{requiredItem.count} +
+ ))} +
+ {filtered.length === 0 && ( +

{t('common:noResults')}

+ )} + ); }; diff --git a/src/components/global/MainNav.tsx b/src/components/global/MainNav.tsx new file mode 100644 index 00000000..63e38f64 --- /dev/null +++ b/src/components/global/MainNav.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation } from 'react-router'; +import type { ReactElement } from 'react'; + +// Persistent top nav, rendered on every page (see App.tsx). Nav links are +// alphabetical by label. Items, Quests, Creatures, Instances, and +// Storylines each have their own route and full page layout; the +// ranked leaderboard page intentionally isn't added here. +export const MainNav = (): ReactElement => { + const { t } = useTranslation(); + const { pathname } = useLocation(); + + return ( +
+
+
  • + {t('pages:home.showCreatures')} +
  • +
  • + {t('pages:home.showGuildLeaderboard')} +
  • +
  • + {t('pages:home.showInstances')} +
  • +
  • + {t('pages:home.showItems')} +
  • +
  • + {t('pages:home.showPlayerLeaderboard')} +
  • +
  • + {t('pages:home.showQuests')} +
  • +
  • + {t('pages:home.showScenarios')} +
  • +
  • + {t('pages:home.showSkirmishes')} +
  • +
  • + {t('pages:home.showStorylines')} +
  • +
    +
    + ); +}; diff --git a/src/components/global/SearchBox.tsx b/src/components/global/SearchBox.tsx index f6e23812..857db8c3 100644 --- a/src/components/global/SearchBox.tsx +++ b/src/components/global/SearchBox.tsx @@ -1,39 +1,125 @@ import type { ReactElement } from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +const DEBOUNCE_MS = 300; + export const SearchBox = ({ initialQuery, onSubmit, isPlayer, + navigateOnSubmit, }: { initialQuery?: string; onSubmit?: (query: string) => void; isPlayer?: boolean; + navigateOnSubmit?: boolean; }): ReactElement => { const { t } = useTranslation('components'); const navigate = useNavigate(); const [query, setQuery] = useState(initialQuery ?? ''); + const debounceRef = useRef>(undefined); + const inputRef = useRef(null); + // Tracks the last value *this component* pushed out via commit(), so the + // effect below can tell "the URL changed because we just typed and our + // own debounced commit landed" apart from "the URL changed for some + // other reason (browser back/forward, parent reset)". Without that + // distinction, every commit's resulting initialQuery update would + // immediately feed back in and could clobber a keystroke (e.g. + // backspace) typed in the gap before that round trip resolved. + const lastCommittedRef = useRef(initialQuery ?? ''); + + useEffect(() => { + const next = initialQuery ?? ''; + if (next !== lastCommittedRef.current) { + lastCommittedRef.current = next; + setQuery(next); + } + }, [initialQuery]); + + useEffect(() => { + return () => { + clearTimeout(debounceRef.current); + }; + }, []); + + // navigateOnSubmit boxes (Home's player/guild tabs, plus the dedicated + // Search/SearchGuild pages) can go from one page to a completely + // different one mid-search: committing a debounced keystroke navigates + // from e.g. /guilds to /search/guild/:query, which swaps in a whole new + // route element and mounts a brand-new input. The old input's focus + // goes with it, so without this the user has to click back into the + // box to keep typing. Runs once per mount, which lines up with exactly + // that one moment - staying on the same search results page for + // further keystrokes only updates the URL param, it doesn't remount. + useEffect(() => { + if (!navigateOnSubmit) { + return; + } + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + const end = input.value.length; + input.setSelectionRange(end, end); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const commit = (value: string): void => { + clearTimeout(debounceRef.current); + lastCommittedRef.current = value; + // navigateOnSubmit is only used by the dedicated player/guild search + // pages, which are keyed off the query in the URL (/search/:query and + // /search/guild/:query). Every other caller (Creatures, Items, Quests, + // Instances, ...) just wants the callback -- it must NOT navigate, + // since that used to unconditionally send users to guild search. + // + // An empty value gets its own destination instead of navigating to + // `/search/guild/` (trailing empty segment). There's no route for + // that exact shape, and bare `/search/guild` actually matches the + // player route `/search/:query` with query="guild" - landing back on + // this box showing the literal word "guild" instead of clearing. + if (navigateOnSubmit) { + if (value) { + void navigate( + isPlayer ? `/search/${value}` : `/search/guild/${value}`, + { replace: true }, + ); + } else { + void navigate(isPlayer ? '/' : '/guilds', { replace: true }); + } + } + if (onSubmit) { + onSubmit(value); + } + }; return (
    { e.preventDefault(); - void navigate(isPlayer ? `/search/${query}` : `/search/guild/${query}`); - if (onSubmit) { - onSubmit(query); - } + commit(query); }} >

    setQuery(e.target.value)} + value={query} + onChange={(e) => { + const { value } = e.target; + setQuery(value); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout( + () => commit(value), + DEBOUNCE_MS, + ); + }} /> diff --git a/src/components/instance_run/InstanceRunsFilters.tsx b/src/components/instance_run/InstanceRunsFilters.tsx index aac83557..36f93594 100644 --- a/src/components/instance_run/InstanceRunsFilters.tsx +++ b/src/components/instance_run/InstanceRunsFilters.tsx @@ -2,17 +2,25 @@ import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router'; import type { InstanceRunFilterInput } from '@/__generated__/graphql'; import type { ReactElement } from 'react'; +import { INSTANCE_GROUPS } from '@/utils/instanceGroups'; const getInstanceFilters = ( search: URLSearchParams, ): InstanceRunFilterInput => { const instance = search.get('instance'); - if (instance && instance !== 'all') { - return { instanceId: { eq: Number(instance) } }; + if (!instance || instance === 'all') { + return {}; } - return {}; + const group = INSTANCE_GROUPS.find( + (candidate) => candidate.id === Number(instance), + ); + const instanceIds = group?.instanceIds ?? [Number(instance)]; + + return instanceIds.length === 1 + ? { instanceId: { eq: instanceIds[0] } } + : { instanceId: { in: instanceIds } }; }; const getCompletedEncountersFilters = ( @@ -27,129 +35,9 @@ const getCompletedEncountersFilters = ( return {}; }; -const getMinItemRatingFilters = ( - search: URLSearchParams, -): InstanceRunFilterInput => { - const minItemRatingMin = search.get('minItemRatingMin'); - const minItemRatingMax = search.get('minItemRatingMax'); - - if ( - minItemRatingMin && - minItemRatingMin !== '0' && - minItemRatingMax && - minItemRatingMax !== '0' - ) { - return { - minItemRating: { - gte: Number(minItemRatingMin), - lte: Number(minItemRatingMax), - }, - }; - } - - if (minItemRatingMin && minItemRatingMin !== '0') { - return { - minItemRating: { - gte: Number(minItemRatingMin), - }, - }; - } - - if (minItemRatingMax && minItemRatingMax !== '0') { - return { - minItemRating: { - lte: Number(minItemRatingMax), - }, - }; - } - - return {}; -}; - -const getAvgItemRatingFilters = ( - search: URLSearchParams, -): InstanceRunFilterInput => { - const avgItemRatingMin = search.get('avgItemRatingMin'); - const avgItemRatingMax = search.get('avgItemRatingMax'); - - if ( - avgItemRatingMin && - avgItemRatingMin !== '0' && - avgItemRatingMax && - avgItemRatingMax !== '0' - ) { - return { - averageItemRating: { - gte: Number(avgItemRatingMin), - lte: Number(avgItemRatingMax), - }, - }; - } - - if (avgItemRatingMin && avgItemRatingMin !== '0') { - return { - averageItemRating: { - gte: Number(avgItemRatingMin), - }, - }; - } - - if (avgItemRatingMax && avgItemRatingMax !== '0') { - return { - averageItemRating: { - lte: Number(avgItemRatingMax), - }, - }; - } - - return {}; -}; - -const getMaxItemRatingFilters = ( - search: URLSearchParams, -): InstanceRunFilterInput => { - const maxItemRatingMin = search.get('maxItemRatingMin'); - const maxItemRatingMax = search.get('maxItemRatingMax'); - - if ( - maxItemRatingMin && - maxItemRatingMin !== '0' && - maxItemRatingMax && - maxItemRatingMax !== '0' - ) { - return { - maxItemRating: { - gte: Number(maxItemRatingMin), - lte: Number(maxItemRatingMax), - }, - }; - } - - if (maxItemRatingMin && maxItemRatingMin !== '0') { - return { - maxItemRating: { - gte: Number(maxItemRatingMin), - }, - }; - } - - if (maxItemRatingMax && maxItemRatingMax !== '0') { - return { - maxItemRating: { - lte: Number(maxItemRatingMax), - }, - }; - } - - return {}; -}; - export const getInstanceRunsFilters = (search: URLSearchParams) => ({ ...getInstanceFilters(search), ...getCompletedEncountersFilters(search), - ...getMinItemRatingFilters(search), - ...getAvgItemRatingFilters(search), - ...getMaxItemRatingFilters(search), }); export const InstanceRunsFilters = (): ReactElement => { @@ -159,233 +47,44 @@ export const InstanceRunsFilters = (): ReactElement => { const completedEncounters = search.get('completedEncounters') && Number(search.get('completedEncounters') ?? 0); - const minItemRatingMin = - search.get('minItemRatingMin') && - Number(search.get('minItemRatingMin') ?? 0); - const minItemRatingMax = - search.get('minItemRatingMax') && - Number(search.get('minItemRatingMax') ?? 0); - const avgItemRatingMin = - search.get('minItemRatingMax') && - Number(search.get('avgItemRatingMin') ?? 0); - const avgItemRatingMax = - search.get('minItemRatingMax') && - Number(search.get('avgItemRatingMax') ?? 0); - const maxItemRatingMin = - search.get('maxItemRatingMax') && - Number(search.get('maxItemRatingMin') ?? 0); - const maxItemRatingMax = - search.get('maxItemRatingMax') && - Number(search.get('maxItemRatingMax') ?? 0); return ( -

    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    - { - search.set('completedEncounters', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Min item rating
    -
    - -
    - { - search.set('minItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('minItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Average item rating
    -
    - -
    - { - search.set('avgItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('avgItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Max item rating
    -
    - -
    - { - search.set('maxItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('maxItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    +
    +
    + +
    ); }; diff --git a/src/components/instance_run/InstanceRunsList.tsx b/src/components/instance_run/InstanceRunsList.tsx index f9187ae4..05ed053c 100644 --- a/src/components/instance_run/InstanceRunsList.tsx +++ b/src/components/instance_run/InstanceRunsList.tsx @@ -7,13 +7,16 @@ import { formatISO, intervalToDuration, } from 'date-fns'; +import { useMemo } from 'react'; import { Link, useSearchParams } from 'react-router'; import type { Query } from '@/__generated__/graphql'; import { Archetype } from '@/__generated__/graphql'; import useWindowDimensions from '@/hooks/useWindowDimensions'; +import { SortConfigDirection, useSortableData } from '@/hooks/useSortableData'; import { ErrorMessage } from '@/components/global/ErrorMessage'; import { getInstanceRunsFilters } from '@/components/instance_run/InstanceRunsFilters'; import { QueryPagination } from '@/components/global/QueryPagination'; +import { parseIsoDuration } from '@/utils'; import clsx from 'clsx'; const INSTANCE_RUNS = gql` @@ -43,7 +46,6 @@ const INSTANCE_RUNS = gql` name } scoreboardEntries { - itemRating deaths archetype damage @@ -66,6 +68,47 @@ const INSTANCE_RUNS = gql` } `; +// The API's averageDuration aggregate isn't reliable for instances with a lot +// of history - runs that never got a proper `end` written (abandoned/never- +// closed sessions) can drag it into the thousands of days (see comment below +// on averageDurationParsed). Sampling the most recent runs and computing a +// duration average client-side, excluding implausible outliers, gives users +// something honest to look at in the meantime. This should go away once the +// API filters bad rows out of its own aggregate - see the notes for dalen. +// Capped at 50 because that's the API's hard per-request page-size ceiling +// (HC0051) - a bigger "sample" would need to paginate across multiple +// requests, which starts to look like the kind of separate client-side sync +// logic we're trying to avoid. +const DURATION_SAMPLE_SIZE = 50; +const MAX_PLAUSIBLE_DURATION_MS = 7 * 60 * 60 * 1000; // 7 hours + +const INSTANCE_RUNS_DURATION_SAMPLE = gql` + query GetInstanceRunsDurationSample( + $first: Int + $where: InstanceRunFilterInput + ) { + instanceRuns(first: $first, where: $where, order: { start: DESC }) { + nodes { + start + end + } + } + } +`; + +interface InstanceRunRow { + deaths: number; + durationMs: number; + encounters: number; + end: string; + id: string; + instanceName: string; + numDPS: number; + numHealers: number; + numTanks: number; + start: string; +} + export const InstanceRunsList = () => { const perPage = 25; @@ -77,9 +120,90 @@ export const InstanceRunsList = () => { where: getInstanceRunsFilters(search), }, }); + const { data: durationSampleData } = useQuery( + INSTANCE_RUNS_DURATION_SAMPLE, + { + variables: { + first: DURATION_SAMPLE_SIZE, + where: getInstanceRunsFilters(search), + }, + }, + ); const { width } = useWindowDimensions(); const isMobile = width <= 768; + const durationSample = useMemo(() => { + const nodes = durationSampleData?.instanceRuns?.nodes ?? []; + const durationsMs = nodes + .map( + (node) => new Date(node.end).getTime() - new Date(node.start).getTime(), + ) + .filter((ms) => Number.isFinite(ms) && ms >= 0); + const saneDurationsMs = durationsMs.filter( + (ms) => ms <= MAX_PLAUSIBLE_DURATION_MS, + ); + + if (saneDurationsMs.length === 0) { + return null; + } + + return { + averageMs: + saneDurationsMs.reduce((a, b) => a + b, 0) / saneDurationsMs.length, + excluded: durationsMs.length - saneDurationsMs.length, + sampleSize: durationsMs.length, + }; + }, [durationSampleData]); + + const rows = useMemo( + () => + (data?.instanceRuns?.nodes ?? []).map((instanceRun) => { + return { + deaths: instanceRun.scoreboardEntries + .map((entry) => entry.deaths) + .reduce((a, b) => a + b, 0), + durationMs: + new Date(instanceRun.end).getTime() - + new Date(instanceRun.start).getTime(), + encounters: new Set( + instanceRun.encounters.map((e) => e.encounterId), + ).size, + end: instanceRun.end, + id: instanceRun.id, + instanceName: instanceRun.instance.name, + numDPS: instanceRun.scoreboardEntries.filter((entry) => + [Archetype.MeleeDps, Archetype.RangedDps].includes( + entry.archetype, + ), + ).length, + numHealers: instanceRun.scoreboardEntries.filter( + (entry) => entry.archetype === Archetype.Healer, + ).length, + numTanks: instanceRun.scoreboardEntries.filter( + (entry) => entry.archetype === Archetype.Tank, + ).length, + start: instanceRun.start, + }; + }), + [data], + ); + + const { + items: sortedRows, + requestSort, + sortConfig, + } = useSortableData(rows, { + direction: SortConfigDirection.descending, + key: 'start', + }); + + const getSortClass = (key: string): string => { + if (!sortConfig || sortConfig.key !== key) { + return ''; + } + return sortConfig.direction; + }; + if (data?.instanceRuns?.nodes?.length === 0) { return

    {t('common:noResults')}

    ; } @@ -93,12 +217,27 @@ export const InstanceRunsList = () => { const { pageInfo } = data.instanceRuns; - const averageDurationObject = intervalToDuration({ - end: new Date(Math.round(data.instanceRuns.averageDuration)), - start: new Date(0), - }); + // A meaningful chunk of instanceRuns rows never get a proper `end` written + // (abandoned/never-closed sessions), which can drag the API's averageDuration + // aggregate into the thousands of days for instances with a lot of history. + // Prefer a duration computed from a recent sample with implausible outliers + // (>7h) excluded - it's honest about its scope (see the note rendered below) + // rather than hiding the problem or showing a nonsensical number. Fall back + // to the raw API aggregate only when the sample query hasn't returned + // anything usable yet. + const averageDurationParsed = parseIsoDuration( + data.instanceRuns.averageDuration, + ); + const apiAverageIsPlausible = (averageDurationParsed.days ?? 0) < 1; - const averageDuration = formatDuration(averageDurationObject); + let averageDuration = t('pages:instanceRuns.averageDurationUnavailable'); + if (durationSample != null) { + averageDuration = formatDuration( + intervalToDuration({ end: durationSample.averageMs, start: 0 }), + ); + } else if (apiAverageIsPlausible) { + averageDuration = formatDuration(averageDurationParsed); + } return ( <> @@ -108,6 +247,14 @@ export const InstanceRunsList = () => { {`${t('pages:instanceRuns.averageDuration')} `} {averageDuration}

    + {durationSample != null && durationSample.excluded > 0 && ( +

    + {t('pages:instanceRuns.averageDurationSampleNote', { + excluded: durationSample.excluded, + sampleSize: durationSample.sampleSize, + })} +

    + )}
    { isMobile ? 'is-narrow' : 'is-fullwidth', )} > - + - - - - - {' '} - - - - + + + + + - - - {data.instanceRuns.nodes.map((instanceRun) => { - const startDate = new Date(instanceRun.start); - const endDate = new Date(instanceRun.end); - const durationObject = intervalToDuration({ - end: endDate, - start: startDate, - }); - - const duration = formatDuration(durationObject); - const itemRatings = instanceRun.scoreboardEntries.map( - (e) => e.itemRating, - ); - const itemRatingMin = Math.min(...itemRatings); - const itemRatingMax = Math.max(...itemRatings); - const itemRatingAverage = - itemRatings.reduce((a, b) => a + b) / itemRatings.length; - const numTanks = instanceRun.scoreboardEntries.filter( - (e) => e.archetype === Archetype.Tank, - ).length; - const numHealers = instanceRun.scoreboardEntries.filter( - (e) => e.archetype === Archetype.Healer, - ).length; - const numDPS = instanceRun.scoreboardEntries.filter((e) => - [Archetype.MeleeDps, Archetype.RangedDps].includes(e.archetype), - ).length; - - const numEncounters = new Set( - instanceRun.encounters.map((e) => e.encounterId), - ).size; + {sortedRows.map((row) => { + const startDate = new Date(row.start); return ( - + - - - - + - - - - - - + + + + + - + + ); +}; + +const TeamSection = ({ entries, + realm, + sortDirection, + sortKey, + onSort, }: { entries: ScenarioScoreboardEntryFragment[]; + realm: 'order' | 'destruction'; + sortDirection: 'asc' | 'desc'; + sortKey: StatKey; + onSort: (key: StatKey) => void; }): ReactElement => { - const { items, requestSort, sortConfig } = useSortableData(entries); const { t } = useTranslation(['components']); - const getClassName = (name: string) => { - if (!sortConfig) { - return ''; - } - return sortConfig.key === name ? sortConfig.direction : ''; + const statLabels: Record = { + damage: t('components:scenarioScoreboard.damage'), + deathBlows: t('components:scenarioScoreboard.dbs'), + deaths: t('components:scenarioScoreboard.deaths'), + healing: t('components:scenarioScoreboard.healing'), + killDamage: t('components:scenarioScoreboard.killDamage'), + kills: t('components:scenarioScoreboard.kills'), + objectiveScore: t('components:scenarioScoreboard.objectiveScore'), + protection: t('components:scenarioScoreboard.protection'), }; + const totals = STAT_KEYS.reduce( + (acc, key) => { + acc[key] = entries.reduce((sum, entry) => sum + Number(entry[key]), 0); + return acc; + }, + {} as Record, + ); + + const roleGroups = scenarioRoleOrder + .map((role) => ({ + role, + roleEntries: entries + .filter((entry) => scenarioCareerRoles[entry.character.career] === role) + .toSorted((left, right) => { + const comparison = Number(left[sortKey]) - Number(right[sortKey]); + return sortDirection === 'asc' ? comparison : -comparison; + }), + })) + .filter((group) => group.roleEntries.length > 0); + return ( -
    -
    {t('pages:instanceRuns.startTime')}{t('pages:instanceRuns.instance')}{t('pages:instanceRuns.duration')}{t('pages:instanceRuns.encounters')} - - {t('pages:instanceRuns.deaths') - - {t('pages:instanceRuns.itemRatingMin')}{t('pages:instanceRuns.itemRatingAverage')}{t('pages:instanceRuns.itemRatingMax')} + requestSort('start')} + > + {t('pages:instanceRuns.startTime')} + requestSort('instanceName')} + > + {t('pages:instanceRuns.instance')} + requestSort('durationMs')} + > + {t('pages:instanceRuns.duration')} + requestSort('encounters')} + > + {t('pages:instanceRuns.encounters')} + requestSort('deaths')} + > + {t('pages:instanceRuns.deaths')} + requestSort('numTanks')} + > { /> + requestSort('numHealers')} + > { /> + requestSort('numDPS')} + > {
    {formatISO(startDate, { representation: 'date' })} @@ -214,23 +391,23 @@ export const InstanceRunsList = () => { {format(startDate, 'HH:mm')} {instanceRun.instance.name}{duration}{numEncounters} - {instanceRun.scoreboardEntries - .map((e) => e.deaths) - .reduce((a, b) => a + b, 0)} + {row.instanceName} + {formatDuration( + intervalToDuration({ + end: new Date(row.end), + start: startDate, + }), + )} {itemRatingMin}{itemRatingAverage.toFixed(0)}{itemRatingMax}{numTanks}{numHealers}{numDPS}{row.encounters}{row.deaths}{row.numTanks}{row.numHealers}{row.numDPS} {t('common:details')} diff --git a/src/components/instance_statistics/InstanceEncounterRunsFilters.tsx b/src/components/instance_statistics/InstanceEncounterRunsFilters.tsx index a134fc30..a377dea1 100644 --- a/src/components/instance_statistics/InstanceEncounterRunsFilters.tsx +++ b/src/components/instance_statistics/InstanceEncounterRunsFilters.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router'; import type { InstanceEncounterRunFilterInput } from '@/__generated__/graphql'; import type { ReactElement } from 'react'; @@ -14,320 +15,39 @@ const getCompletedEncountersFilters = ( return {}; }; -const getMinItemRatingFilters = ( - search: URLSearchParams, -): InstanceEncounterRunFilterInput => { - const minItemRatingMin = search.get('minItemRatingMin'); - const minItemRatingMax = search.get('minItemRatingMax'); - - if ( - minItemRatingMin && - minItemRatingMin !== '0' && - minItemRatingMax && - minItemRatingMax !== '0' - ) { - return { - minItemRating: { - gte: Number(minItemRatingMin), - lte: Number(minItemRatingMax), - }, - }; - } - - if (minItemRatingMin && minItemRatingMin !== '0') { - return { - minItemRating: { - gte: Number(minItemRatingMin), - }, - }; - } - - if (minItemRatingMax && minItemRatingMax !== '0') { - return { - minItemRating: { - lte: Number(minItemRatingMax), - }, - }; - } - - return {}; -}; - -const getAvgItemRatingFilters = ( - search: URLSearchParams, -): InstanceEncounterRunFilterInput => { - const avgItemRatingMin = search.get('avgItemRatingMin'); - const avgItemRatingMax = search.get('avgItemRatingMax'); - - if ( - avgItemRatingMin && - avgItemRatingMin !== '0' && - avgItemRatingMax && - avgItemRatingMax !== '0' - ) { - return { - averageItemRating: { - gte: Number(avgItemRatingMin), - lte: Number(avgItemRatingMax), - }, - }; - } - - if (avgItemRatingMin && avgItemRatingMin !== '0') { - return { - averageItemRating: { - gte: Number(avgItemRatingMin), - }, - }; - } - - if (avgItemRatingMax && avgItemRatingMax !== '0') { - return { - averageItemRating: { - lte: Number(avgItemRatingMax), - }, - }; - } - - return {}; -}; - -const getMaxItemRatingFilters = ( - search: URLSearchParams, -): InstanceEncounterRunFilterInput => { - const maxItemRatingMin = search.get('maxItemRatingMin'); - const maxItemRatingMax = search.get('maxItemRatingMax'); - - if ( - maxItemRatingMin && - maxItemRatingMin !== '0' && - maxItemRatingMax && - maxItemRatingMax !== '0' - ) { - return { - maxItemRating: { - gte: Number(maxItemRatingMin), - lte: Number(maxItemRatingMax), - }, - }; - } - - if (maxItemRatingMin && maxItemRatingMin !== '0') { - return { - maxItemRating: { - gte: Number(maxItemRatingMin), - }, - }; - } - - if (maxItemRatingMax && maxItemRatingMax !== '0') { - return { - maxItemRating: { - lte: Number(maxItemRatingMax), - }, - }; - } - - return {}; -}; +// The API's `start` filter is a DateTime field, not a raw timestamp number - +// passing the number 0 (as this used to) makes the whole request 400 with +// "DateTime cannot coerce the given value JSON element of type Number". +// The Unix epoch as an ISO-8601 string keeps the original intent (exclude +// any zero/unset start dates) with a value the API actually accepts. +const EPOCH = '1970-01-01T00:00:00.000Z'; export const getInstanceEncounterRunsFilters = (search: URLSearchParams) => ({ scoreboardEntryCount: { gte: 6 }, - start: { gt: 0 }, + start: { gt: EPOCH }, ...getCompletedEncountersFilters(search), - ...getMinItemRatingFilters(search), - ...getAvgItemRatingFilters(search), - ...getMaxItemRatingFilters(search), }); export const InstanceEncounterRunsFilters = (): ReactElement => { + const { t } = useTranslation(['common', 'pages']); const [search, setSearch] = useSearchParams(); const completed = search.get('completed') && Number(search.get('completed') ?? 0); - const minItemRatingMin = - search.get('minItemRatingMin') && - Number(search.get('minItemRatingMin') ?? 0); - const minItemRatingMax = - search.get('minItemRatingMax') && - Number(search.get('minItemRatingMax') ?? 0); - const avgItemRatingMin = - search.get('minItemRatingMax') && - Number(search.get('avgItemRatingMin') ?? 0); - const avgItemRatingMax = - search.get('minItemRatingMax') && - Number(search.get('avgItemRatingMax') ?? 0); - const maxItemRatingMin = - search.get('maxItemRatingMax') && - Number(search.get('maxItemRatingMin') ?? 0); - const maxItemRatingMax = - search.get('maxItemRatingMax') && - Number(search.get('maxItemRatingMax') ?? 0); return ( -
    -
    -
    -
    -
    - -
    - { - search.set('completed', event.target.checked ? '1' : '0'); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Min item rating
    -
    - -
    - { - search.set('minItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('minItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Average item rating
    -
    - -
    - { - search.set('avgItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('avgItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    Max item rating
    -
    - -
    - { - search.set('maxItemRatingMax', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    - -
    - { - search.set('maxItemRatingMin', event.target.value); - setSearch(search); - }} - /> -
    -
    -
    -
    -
    +
    +
    ); }; diff --git a/src/components/instance_statistics/InstanceEncounterStatistics.tsx b/src/components/instance_statistics/InstanceEncounterStatistics.tsx index 815fe724..310821d5 100644 --- a/src/components/instance_statistics/InstanceEncounterStatistics.tsx +++ b/src/components/instance_statistics/InstanceEncounterStatistics.tsx @@ -3,7 +3,8 @@ import { ErrorMessage } from '@/components/global/ErrorMessage'; import { getInstanceEncounterRunsFilters } from '@/components/instance_statistics/InstanceEncounterRunsFilters'; import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; -import { formatDuration, intervalToDuration } from 'date-fns'; +import { formatDuration } from 'date-fns'; +import { parseIsoDuration } from '@/utils'; import { useSearchParams } from 'react-router'; const INSTANCE_ENCOUNTER_STATISTICS = gql` @@ -57,21 +58,26 @@ export const InstanceEncounterStatistics = ({ return ; } - if (data.instanceEncounterRuns.medianDuration === 0) { + // Encounters with a sub-minute median duration are almost always trash + // pulls or bugged/incomplete fights rather than real boss encounters - + // leave them off the list entirely (this also covers the zero-duration + // "no data" case). + const medianDuration = parseIsoDuration( + data.instanceEncounterRuns.medianDuration, + ); + const medianDurationSeconds = + (medianDuration.days ?? 0) * 86_400 + + (medianDuration.hours ?? 0) * 3600 + + (medianDuration.minutes ?? 0) * 60 + + (medianDuration.seconds ?? 0); + if (medianDurationSeconds < 60) { return null; } return (
    {name} - {formatDuration( - intervalToDuration({ - end: new Date(data.instanceEncounterRuns.medianDuration), - start: new Date(0), - }), - )} - {formatDuration(medianDuration)} {data.instanceEncounterRuns.medianDeaths} {Math.round( diff --git a/src/components/item/ItemVendorsFilters.tsx b/src/components/item/ItemVendorsFilters.tsx index 4bb10b7a..ac561d96 100644 --- a/src/components/item/ItemVendorsFilters.tsx +++ b/src/components/item/ItemVendorsFilters.tsx @@ -26,101 +26,95 @@ export const ItemVendorsFilters = (): ReactElement => { const career = search.get('career') || 'all'; return ( -
    -
    -
    -
    -
    - - -
    -
    +
    +
    +
    ); }; diff --git a/src/components/kill/KillsList.tsx b/src/components/kill/KillsList.tsx index 27e738a2..3c709fa3 100644 --- a/src/components/kill/KillsList.tsx +++ b/src/components/kill/KillsList.tsx @@ -13,7 +13,7 @@ export const KillsList = ({ query, queryOptions, perPage, - title = undefined, + title, showTime = true, showVictim = true, showKiller = true, diff --git a/src/components/scenario/CharacterScenarioConnections.tsx b/src/components/scenario/CharacterScenarioConnections.tsx new file mode 100644 index 00000000..8ef9519c --- /dev/null +++ b/src/components/scenario/CharacterScenarioConnections.tsx @@ -0,0 +1,427 @@ +import { gql } from '@apollo/client'; +import { useApolloClient } from '@apollo/client/react'; +import { type ReactElement, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { + type Career, + type Kill, + type Query, + type ScenarioRecord, +} from '@/__generated__/graphql'; +import { CareerIcon } from '@/components/CareerIcon'; + +const CHARACTER_SCENARIO_DEATHS = gql` + query GetCharacterScenarioDeaths( + $where: KillFilterInput + $first: Int + $after: String + ) { + kills(where: $where, first: $first, after: $after) { + nodes { + id + deathblow { + id + name + career + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +interface ConnectionPlayer { + career: Career; + id: string; + losses: number; + matches: number; + name: string; + wins: number; +} + +const dateInputValue = (date: Date): string => { + const offsetDate = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000, + ); + return offsetDate.toISOString().slice(0, 10); +}; + +const ConnectionTable = ({ + emptyText, + killsOnly = false, + players, + title, +}: { + emptyText: string; + killsOnly?: boolean; + players: ConnectionPlayer[]; + title: string; +}): ReactElement => ( +
    +
    +

    {title}

    + {players.length} players +
    + {players.length === 0 ? ( +

    {emptyText}

    + ) : ( +
    + + + + + + {!killsOnly && ( + <> + + + + + )} + + + + {players.slice(0, 10).map((player) => ( + + + + + {!killsOnly && ( + <> + + + + + )} + + ))} + +
    + Character{killsOnly ? 'Killing blows' : 'Matches'}WLWR
    + + + {player.name} + {player.matches}{player.wins}{player.losses} + {Math.round((player.wins / player.matches) * 100)}% +
    +
    + )} +
    +); + +export const CharacterScenarioConnections = ({ + characterId, + scenarios, +}: { + characterId: string; + scenarios: ScenarioRecord[]; +}): ReactElement => { + const [searchParams, setSearchParams] = useSearchParams(); + const client = useApolloClient(); + const [deathblows, setDeathblows] = useState([]); + const [deathblowsLoading, setDeathblowsLoading] = useState(false); + const queueType = searchParams.get('queue_type') ?? 'all'; + const tier = searchParams.get('tier') ?? 'all'; + const range = searchParams.get('range') ?? 'recent'; + const scenarioIds = scenarios.map((scenario) => scenario.id); + const scenarioIdsKey = scenarioIds.join(','); + + const updateParams = (updates: Record): void => { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) { + next.delete(key); + } else { + next.set(key, value); + } + } + next.delete('lbRealm'); + next.delete('lbRole'); + next.delete('lbCareer'); + next.delete('lbMetric'); + next.delete('lbLimit'); + next.delete('lbMin'); + next.delete('lbMode'); + setSearchParams(next, { replace: true }); + }; + + useEffect(() => { + let cancelled = false; + const loadDeathblows = async (): Promise => { + setDeathblows([]); + if (scenarioIds.length === 0) { + return; + } + setDeathblowsLoading(true); + const loaded: Kill[] = []; + try { + for (let index = 0; index < scenarioIds.length; index += 100) { + const instanceIds = scenarioIds.slice(index, index + 100); + let after: string | undefined; + do { + const result = await client.query({ + errorPolicy: 'all', + fetchPolicy: 'network-only', + query: CHARACTER_SCENARIO_DEATHS, + variables: { + after, + first: 50, + where: { + instanceId: { in: instanceIds }, + victimCharacterId: { eq: characterId }, + }, + }, + }); + const connection = result.data?.kills; + // A malformed row nulls out the whole `nodes` array under + // GraphQL's non-null propagation rules; skip it rather than + // losing the request (or the whole panel) to a thrown error. + loaded.push(...(connection?.nodes ?? [])); + after = connection?.pageInfo.endCursor ?? undefined; + if (!connection?.pageInfo.hasNextPage || !after) { + break; + } + } while (!cancelled); + if (cancelled) { + break; + } + } + if (!cancelled) { + setDeathblows(loaded); + } + } catch { + // This panel is supplementary (deathblow breakdown within a + // character's scenario history) -- on failure, keep whatever we + // already gathered instead of silently discarding it and leaving + // the section looking empty. + if (!cancelled) { + setDeathblows(loaded); + } + } finally { + if (!cancelled) { + setDeathblowsLoading(false); + } + } + }; + void loadDeathblows(); + return () => { + cancelled = true; + }; + }, [characterId, client, scenarioIdsKey]); + + const { opponents, teammates } = useMemo(() => { + const teammateMap = new Map(); + const opponentMap = new Map(); + + for (const scenario of scenarios) { + const ownEntry = scenario.scoreboardEntries.find( + (entry) => entry.character.id === characterId, + ); + if (!ownEntry) { + continue; + } + const won = scenario.winner === ownEntry.team; + for (const entry of scenario.scoreboardEntries) { + if (entry.character.id === characterId) { + continue; + } + const target = entry.team === ownEntry.team ? teammateMap : opponentMap; + const current = target.get(entry.character.id) ?? { + career: entry.character.career, + id: entry.character.id, + losses: 0, + matches: 0, + name: entry.character.name, + wins: 0, + }; + current.matches += 1; + current.wins += won ? 1 : 0; + current.losses += won ? 0 : 1; + target.set(entry.character.id, current); + } + } + const sortPlayers = (players: ConnectionPlayer[]) => + players.toSorted( + (left, right) => + right.matches - left.matches || + right.wins - left.wins || + left.name.localeCompare(right.name), + ); + return { + opponents: sortPlayers([...opponentMap.values()]), + teammates: sortPlayers([...teammateMap.values()]), + }; + }, [characterId, scenarios]); + + const killers = useMemo(() => { + const players = new Map(); + for (const kill of deathblows) { + if (!kill.deathblow) { + continue; + } + const current = players.get(kill.deathblow.id) ?? { + career: kill.deathblow.career, + id: kill.deathblow.id, + losses: 0, + matches: 0, + name: kill.deathblow.name, + wins: 0, + }; + current.matches += 1; + current.losses += 1; + players.set(kill.deathblow.id, current); + } + return [...players.values()].toSorted( + (left, right) => + right.matches - left.matches || left.name.localeCompare(right.name), + ); + }, [deathblows]); + + return ( + <> +
    + + + + {range === 'custom' && ( + <> + + + + )} +
    + {tooltip ? ( + + {inner} + + ) : ( + inner + )} +
    - - - - - - - - - - - - - - - - - - {items.map((entry: ScenarioScoreboardEntryFragment) => ( - - - - - - +
    +
    + {realm} + {realm === 'order' ? 'Order' : 'Destruction'} + {entries.length} players +
    +
    + {STAT_KEYS.map((key) => ( + + {totals[key].toLocaleString()} + {statLabels[key]} + + ))} +
    + {roleGroups.map(({ role, roleEntries }) => ( +
    +
    requestSort('character.career')} - className={`${getClassName('career')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.career')} - requestSort('character.name')} - className={`${getClassName('name')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.name')} - requestSort('guild.name')} - className={`${getClassName('guild')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.guild')} - requestSort('level')} - className={`${getClassName('level')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.rank')} - requestSort('kills')} - className={`${getClassName('kills')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.kills')} - requestSort('deaths')} - className={`${getClassName('deaths')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.deaths')} - requestSort('deathBlows')} - className={`${getClassName( - 'deathBlows', - )} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.dbs')} - requestSort('damage')} - className={`${getClassName('damage')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.damage')} - requestSort('killDamage')} - className={`${getClassName('killDamage')} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.killDamage')} - requestSort('healing')} - className={`${getClassName( - 'healing', - )} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.healing')} - requestSort('protection')} - className={`${getClassName( - 'protection', - )} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.protection')} - requestSort('objectiveScore')} - className={`${getClassName( - 'objectiveScore', - )} is-clickable has-text-link`} - > - {t('components:scenarioScoreboard.objectiveScore')} -
    - - - - {entry.character.name} - - - {entry.guild && ( - - - - )} - - {entry.guild && ( - - {entry.guild.name} - - )} - {entry.level}
    + + + + + + + + {STAT_KEYS.map((key) => ( + + ))} + + + + {roleEntries.map((entry) => ( + + + + + + + Solo Kills: {entry.killsSolo} + + } + /> + + Damage Receive: {entry.damageReceived} +
    + Healing Received: {entry.healingReceived} +
    + Protection Received: {entry.protectionReceived} + + } + /> + + + + + Healing of Self: {entry.healingSelf} +
    + Healing of Others: {entry.healingOthers} +
    + Resurrections Done: {entry.resurrectionsDone} + + } + /> + + Protection of Self: {entry.protectionSelf} +
    + Protection of Others: {entry.protectionOthers} + + } + /> + +
    + ))} + +
    + {role} ({roleEntries.length}) +
    + {t('components:scenarioScoreboard.name')}{t('components:scenarioScoreboard.guild')} { + onSort(key); + }} + > + {statLabels[key]} + {sortKey === key && (sortDirection === 'asc' ? ' ▲' : ' ▼')} +
    + + + + {entry.character.name} + + + CR {entry.level} · RR {entry.renownRank} + + + {entry.guild && ( + + + + )} + + {entry.guild && ( + + {entry.guild.name} + + )} +
    +
    + ))} + + ); +}; + +export const ScenarioScoreboard = ({ + entries, +}: { + entries: ScenarioScoreboardEntryFragment[]; +}): ReactElement => { + const [sortKey, setSortKey] = useState('killDamage'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); - - - Solo Kills: {entry.killsSolo} - - } - > - {entry.kills} - - - - - Damage Receive: {entry.damageReceived} -
    - Healing Received: {entry.healingReceived} -
    - Protection Received: {entry.protectionReceived} - - } - > - {entry.deaths} -
    - - {entry.deathBlows} - - {Number(entry.damage).toLocaleString()} - - - {Number(entry.killDamage).toLocaleString()} - - - - Healing of Self: {entry.healingSelf} -
    - Healing of Others: {entry.healingOthers} -
    - Resurrections Done: {entry.resurrectionsDone} - - } - > - {Number(entry.healing).toLocaleString()} -
    - - - - Protection of Self: {entry.protectionSelf} -
    - Protection of Others: {entry.protectionOthers} - - } - > - {Number(entry.protection).toLocaleString()} -
    - - - {Number(entry.objectiveScore).toLocaleString()} - - - ))} - - + const handleSort = (key: StatKey): void => { + if (key === sortKey) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + const orderEntries = entries.filter((entry) => entry.team === 0); + const destructionEntries = entries.filter((entry) => entry.team === 1); + + return ( +
    + +
    ); }; diff --git a/src/components/scenario/ScenarioStandouts.tsx b/src/components/scenario/ScenarioStandouts.tsx new file mode 100644 index 00000000..74b84efc --- /dev/null +++ b/src/components/scenario/ScenarioStandouts.tsx @@ -0,0 +1,1676 @@ +import { type ReactElement, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { Career, type ScenarioRecord } from '@/__generated__/graphql'; +import { CareerIcon } from '@/components/CareerIcon'; +import { + type ScenarioRole, + scenarioCareerName, + scenarioCareerRoles, + scenarioRoleOrder, +} from '@/components/scenario/scenarioRoles'; +import { assetUrl } from '@/utils'; + +type RealmFilter = 'both' | 'order' | 'destruction'; +type RoleFilter = 'all' | ScenarioRole; +type CareerFilter = 'all' | Career; +type RankingMode = 'totals' | 'average' | 'median'; +type TableSortKey = + | 'career' + | 'name' + | 'scenarios' + | 'wins' + | 'winRate' + | 'kills' + | 'killDamage' + | 'damage' + | 'deathBlows' + | 'healing' + | 'protection' + | 'objectiveScore'; +type RankingMetric = + | 'wins' + | 'winRate' + | 'kills' + | 'killDamage' + | 'damage' + | 'deathBlows' + | 'healing' + | 'protection' + | 'objectiveScore'; +type ContributionMetric = + | 'kills' + | 'killDamage' + | 'damage' + | 'deathBlows' + | 'healing' + | 'protection' + | 'objectiveScore'; + +interface Standout { + career: Career; + characterId: string; + damage: number; + deathBlows: number; + healing: number; + killDamage: number; + kills: number; + medians: Record; + name: string; + objectiveScore: number; + protection: number; + scenarios: number; + winRate: number; + wins: number; +} + +const metricLabels: Record = { + wins: 'Wins', + winRate: 'Win rate', + kills: 'Kills', + killDamage: 'Kill damage', + damage: 'Damage', + deathBlows: 'Death blows', + healing: 'Healing', + protection: 'Protection', + objectiveScore: 'Objective score', +}; + +const rankingMetrics = Object.keys(metricLabels) as RankingMetric[]; +const realmFilters: RealmFilter[] = ['both', 'order', 'destruction']; +const roleFilters: RoleFilter[] = ['all', ...scenarioRoleOrder]; + +const compactNumber = (value: number): string => + new Intl.NumberFormat('en', { + maximumFractionDigits: 1, + notation: 'compact', + }).format(value); + +const dateInputValue = (date: Date): string => { + const offsetDate = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000, + ); + return offsetDate.toISOString().slice(0, 10); +}; + +const contributionMetrics: ContributionMetric[] = [ + 'kills', + 'killDamage', + 'damage', + 'deathBlows', + 'healing', + 'protection', + 'objectiveScore', +]; + +const median = (values: number[]): number => { + if (values.length === 0) { + return 0; + } + const sorted = values.toSorted((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +}; + +const getStandouts = ({ + career, + limit, + metric, + minimumScenarios, + mode, + role, + scenarios, + team, +}: { + career: CareerFilter; + limit: number; + metric: RankingMetric; + minimumScenarios: number; + mode: RankingMode; + role: RoleFilter; + scenarios: ScenarioRecord[]; + team: number; +}): Standout[] => { + const characters = new Map< + string, + Omit & { + samples: Record; + } + >(); + + for (const scenario of scenarios) { + for (const entry of scenario.scoreboardEntries) { + if (entry.team === team) { + const current = characters.get(entry.character.id) ?? { + career: entry.character.career, + characterId: entry.character.id, + damage: 0, + deathBlows: 0, + healing: 0, + killDamage: 0, + kills: 0, + name: entry.character.name, + objectiveScore: 0, + protection: 0, + samples: { + damage: [], + deathBlows: [], + healing: [], + killDamage: [], + kills: [], + objectiveScore: [], + protection: [], + }, + scenarios: 0, + wins: 0, + }; + + current.damage += entry.damage; + current.deathBlows += entry.deathBlows; + current.healing += entry.healing; + current.killDamage += entry.killDamage; + current.kills += entry.kills; + current.objectiveScore += entry.objectiveScore; + current.protection += entry.protection; + for (const contributionMetric of contributionMetrics) { + current.samples[contributionMetric].push(entry[contributionMetric]); + } + current.scenarios += 1; + current.wins += scenario.winner === team ? 1 : 0; + characters.set(entry.character.id, current); + } + } + } + + const values = [...characters.values()] + .filter( + (value) => + value.scenarios >= minimumScenarios && + (role === 'all' || scenarioCareerRoles[value.career] === role) && + (career === 'all' || value.career === career), + ) + .map((value): Standout => { + const medians = Object.fromEntries( + contributionMetrics.map((key) => [key, median(value.samples[key])]), + ) as Record; + const { samples: _samples, ...totals } = value; + return { + ...totals, + medians, + winRate: value.wins / value.scenarios, + }; + }); + const contributionValue = ( + value: Standout, + key: ContributionMetric, + ): number => { + if (mode === 'average') { + return value[key] / value.scenarios; + } + if (mode === 'median') { + return value.medians[key]; + } + return value[key]; + }; + return values + .toSorted((left, right) => { + const metricValue = (standout: Standout): number => { + if (contributionMetrics.includes(metric as ContributionMetric)) { + return contributionValue(standout, metric as ContributionMetric); + } + return standout[metric]; + }; + + return ( + metricValue(right) - metricValue(left) || + right.scenarios - left.scenarios || + right.kills - left.kills + ); + }) + .slice(0, limit); +}; + +interface ScenarioBreakdownEntry { + averageDurationSeconds: number; + averagePlayers: number; + destructionWins: number; + id: string; + matches: ScenarioRecord[]; + name: string; + orderWins: number; + topPlayer?: Standout; +} + +type ScenarioBreakdownSortKey = + | 'name' + | 'matches' + | 'order' + | 'destruction' + | 'players' + | 'duration' + | 'balance'; + +const ScenarioBreakdown = ({ + scenarios, +}: { + scenarios: ScenarioRecord[]; +}): ReactElement => { + const [expandedScenarioId, setExpandedScenarioId] = useState(); + const [minimumMatches, setMinimumMatches] = useState(1); + const [sortKey, setSortKey] = useState('matches'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const breakdown = useMemo(() => { + const grouped = new Map< + string, + { + durationSeconds: number; + id: string; + matches: ScenarioRecord[]; + name: string; + orderWins: number; + destructionWins: number; + players: number; + } + >(); + + for (const scenario of scenarios) { + const key = scenario.scenario.id; + const current = grouped.get(key) ?? { + destructionWins: 0, + durationSeconds: 0, + id: key, + matches: [], + name: scenario.scenario.name, + orderWins: 0, + players: 0, + }; + current.matches.push(scenario); + current.players += scenario.numPlayers; + current.durationSeconds += Math.max( + 0, + (new Date(scenario.endTime).getTime() - + new Date(scenario.startTime).getTime()) / + 1000, + ); + if (scenario.winner === 0) { + current.orderWins += 1; + } else if (scenario.winner === 1) { + current.destructionWins += 1; + } + grouped.set(key, current); + } + + return [...grouped.values()] + .map((group): ScenarioBreakdownEntry => { + const topPlayers = [ + ...getStandouts({ + career: 'all', + limit: 1, + metric: 'kills', + minimumScenarios: 1, + mode: 'totals', + role: 'all', + scenarios: group.matches, + team: 0, + }), + ...getStandouts({ + career: 'all', + limit: 1, + metric: 'kills', + minimumScenarios: 1, + mode: 'totals', + role: 'all', + scenarios: group.matches, + team: 1, + }), + ].toSorted( + (left, right) => + right.kills - left.kills || + right.scenarios - left.scenarios || + right.kills - left.kills, + ); + + return { + averageDurationSeconds: + group.durationSeconds / Math.max(group.matches.length, 1), + averagePlayers: group.players / Math.max(group.matches.length, 1), + destructionWins: group.destructionWins, + id: group.id, + matches: group.matches.toSorted( + (left, right) => + new Date(right.startTime).getTime() - + new Date(left.startTime).getTime(), + ), + name: group.name, + orderWins: group.orderWins, + topPlayer: topPlayers[0], + }; + }) + .toSorted( + (left, right) => + right.matches.length - left.matches.length || + left.name.localeCompare(right.name), + ); + }, [scenarios]); + + const visibleBreakdown = useMemo(() => { + const valueFor = ( + entry: ScenarioBreakdownEntry, + key: ScenarioBreakdownSortKey, + ): number | string => { + const completedMatches = entry.orderWins + entry.destructionWins; + const orderRate = + completedMatches > 0 ? entry.orderWins / completedMatches : 0.5; + + switch (key) { + case 'name': + return entry.name; + case 'matches': + return entry.matches.length; + case 'order': + return orderRate; + case 'destruction': + return 1 - orderRate; + case 'players': + return entry.averagePlayers; + case 'duration': + return entry.averageDurationSeconds; + case 'balance': + return Math.abs(orderRate - 0.5); + } + }; + + return breakdown + .filter((entry) => entry.matches.length >= minimumMatches) + .toSorted((left, right) => { + const leftValue = valueFor(left, sortKey); + const rightValue = valueFor(right, sortKey); + const comparison = + typeof leftValue === 'string' && typeof rightValue === 'string' + ? leftValue.localeCompare(rightValue) + : Number(leftValue) - Number(rightValue); + return ( + (sortDirection === 'asc' ? comparison : -comparison) || + right.matches.length - left.matches.length || + left.name.localeCompare(right.name) + ); + }); + }, [breakdown, minimumMatches, sortDirection, sortKey]); + + if (breakdown.length === 0) { + return <>; + } + + const sortHeading = ( + label: string, + key: ScenarioBreakdownSortKey, + ): ReactElement => ( +