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 (
-
-
-
-
-
-
- Instance
-
-
-
- {
- search.set('instance', event.target.value);
- setSearch(search);
- }}
- >
- {t('pages:instanceRuns.all')}
- Lost Vale
- Sigmar Crypts
- Bilerot
- Bastion Stair
- Thar'Ignan
- Lord Slaurith
- Kaarn the Vanquisher
- Skull Lord Var'Ithrok
- Gunbad
- Gunbad Nursery
- Gunbad Lab
- Squig Boss
- Gunbad Baracks
- Dragonback Pass (Order)
- Dragonback Pass (Destruction)
- Altdorf Sewers 1
- Altdorf Sewers 2
- Altdorf Sewers 3
- Sacellum 1
- Sacellum 2
- Sacellum 3
-
-
-
-
-
-
- Min completed encounters
-
-
- {
- search.set('completedEncounters', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
Min item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('minItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('minItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
Average item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('avgItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('avgItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
Max item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('maxItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('maxItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
+
+
+ {t('pages:instanceRuns.instance')}
+
+ {
+ search.set('instance', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('pages:instanceRuns.all')}
+ {INSTANCE_GROUPS.map((group) => (
+
+ {group.name}
+
+ ))}
+
-
+
+
+ {t('pages:instanceRuns.minCompletedEncounters')}
+ {
+ search.set('completedEncounters', 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',
)}
>
-
+
- {t('pages:instanceRuns.startTime')}
- {t('pages:instanceRuns.instance')}
- {t('pages:instanceRuns.duration')}
- {t('pages:instanceRuns.encounters')}
-
-
-
-
- {' '}
- {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')}
+ >
{
- {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 (
-
+
{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 (
-
-
-
-
-
-
- Completed only
-
-
- {
- search.set('completed', event.target.checked ? '1' : '0');
- setSearch(search);
- }}
- />
-
-
-
-
-
Min item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('minItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('minItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
Average item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('avgItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('avgItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
Max item rating
-
-
-
-
-
- Below
-
-
- {
- search.set('maxItemRatingMax', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
-
-
- Above
-
-
- {
- search.set('maxItemRatingMin', event.target.value);
- setSearch(search);
- }}
- />
-
-
-
-
-
+
+
+ {t('pages:instanceStatistics.completedOnly')}
+ {
+ search.set('completed', event.target.checked ? '1' : '0');
+ 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 (
-
-
-
-
-
-
- {t('components:itemVendorsFilters.career')}
-
- {
- search.set('career', event.target.value);
- setSearch(search);
- }}
- >
-
- {t('components:itemVendorsFilters.all')}
-
-
- {t(`enums:career.${Career.IronBreaker}`)}
-
-
- {t(`enums:career.${Career.Slayer}`)}
-
-
- {t(`enums:career.${Career.RunePriest}`)}
-
-
- {t(`enums:career.${Career.Engineer}`)}
-
-
- {t(`enums:career.${Career.BlackOrc}`)}
-
-
- {t(`enums:career.${Career.Choppa}`)}
-
-
- {t(`enums:career.${Career.Shaman}`)}
-
-
- {t(`enums:career.${Career.SquigHerder}`)}
-
-
- {t(`enums:career.${Career.WitchHunter}`)}
-
-
- {t(`enums:career.${Career.KnightOfTheBlazingSun}`)}
-
-
- {t(`enums:career.${Career.BrightWizard}`)}
-
-
- {t(`enums:career.${Career.WarriorPriest}`)}
-
-
- {t(`enums:career.${Career.Chosen}`)}
-
-
- {t(`enums:career.${Career.Marauder}`)}
-
-
- {t(`enums:career.${Career.Zealot}`)}
-
-
- {t(`enums:career.${Career.Magus}`)}
-
-
- {t(`enums:career.${Career.SwordMaster}`)}
-
-
- {t(`enums:career.${Career.ShadowWarrior}`)}
-
-
- {t(`enums:career.${Career.WhiteLion}`)}
-
-
- {t(`enums:career.${Career.Archmage}`)}
-
-
- {t(`enums:career.${Career.BlackGuard}`)}
-
-
- {t(`enums:career.${Career.WitchElf}`)}
-
-
- {t(`enums:career.${Career.DiscipleOfKhaine}`)}
-
-
- {t(`enums:career.${Career.Sorcerer}`)}
-
-
-
-
+
+
+ {t('components:itemVendorsFilters.career')}
+
+ {
+ search.set('career', event.target.value);
+ setSearch(search);
+ }}
+ >
+
+ {t('components:itemVendorsFilters.all')}
+
+
+ {t(`enums:career.${Career.IronBreaker}`)}
+
+
+ {t(`enums:career.${Career.Slayer}`)}
+
+
+ {t(`enums:career.${Career.RunePriest}`)}
+
+
+ {t(`enums:career.${Career.Engineer}`)}
+
+
+ {t(`enums:career.${Career.BlackOrc}`)}
+
+
+ {t(`enums:career.${Career.Choppa}`)}
+
+
+ {t(`enums:career.${Career.Shaman}`)}
+
+
+ {t(`enums:career.${Career.SquigHerder}`)}
+
+
+ {t(`enums:career.${Career.WitchHunter}`)}
+
+
+ {t(`enums:career.${Career.KnightOfTheBlazingSun}`)}
+
+
+ {t(`enums:career.${Career.BrightWizard}`)}
+
+
+ {t(`enums:career.${Career.WarriorPriest}`)}
+
+
+ {t(`enums:career.${Career.Chosen}`)}
+
+
+ {t(`enums:career.${Career.Marauder}`)}
+
+
+ {t(`enums:career.${Career.Zealot}`)}
+
+
+ {t(`enums:career.${Career.Magus}`)}
+
+
+ {t(`enums:career.${Career.SwordMaster}`)}
+
+
+ {t(`enums:career.${Career.ShadowWarrior}`)}
+
+
+ {t(`enums:career.${Career.WhiteLion}`)}
+
+
+ {t(`enums:career.${Career.Archmage}`)}
+
+
+ {t(`enums:career.${Career.BlackGuard}`)}
+
+
+ {t(`enums:career.${Career.WitchElf}`)}
+
+
+ {t(`enums:career.${Career.DiscipleOfKhaine}`)}
+
+
+ {t(`enums:career.${Career.Sorcerer}`)}
+
+
-
+
);
};
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}
+ ) : (
+
+
+
+
+
+ Character
+ {killsOnly ? 'Killing blows' : 'Matches'}
+ {!killsOnly && (
+ <>
+ W
+ L
+ WR
+ >
+ )}
+
+
+
+ {players.slice(0, 10).map((player) => (
+
+
+
+
+
+ {player.name}
+
+ {player.matches}
+ {!killsOnly && (
+ <>
+ {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 (
+ <>
+
+
+ Type
+
+ {
+ updateParams({
+ queue_type:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All types
+ Standard
+ Random Scenario
+ City Siege
+ Group Challenge
+
+
+
+
+ Tier
+
+ {
+ updateParams({
+ tier:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All tiers
+ Tier 1
+ Tier 2–3
+ Tier 4
+
+
+
+
+ Time
+
+ {
+ const nextRange = event.target.value;
+ if (nextRange === 'custom') {
+ const today = new Date();
+ const sevenDaysAgo = new Date(
+ today.getTime() - 7 * 24 * 60 * 60 * 1000,
+ );
+ updateParams({
+ from:
+ searchParams.get('from') ?? dateInputValue(sevenDaysAgo),
+ range: 'custom',
+ to: searchParams.get('to') ?? dateInputValue(today),
+ });
+ } else {
+ updateParams({
+ from: undefined,
+ range: nextRange === 'recent' ? undefined : nextRange,
+ to: undefined,
+ });
+ }
+ }}
+ >
+ Most recent
+ Last hour
+ Last 24 hours
+ Last 7 days
+ Last 30 days
+ Last 90 days
+ Year to date
+ Custom dates
+
+
+
+ {range === 'custom' && (
+ <>
+
+ Start
+ {
+ updateParams({ from: event.target.value || undefined });
+ }}
+ />
+
+
+ End
+ {
+ updateParams({ to: event.target.value || undefined });
+ }}
+ />
+
+ >
+ )}
+
{
+ const next = new URLSearchParams();
+ setSearchParams(next, { replace: true });
+ }}
+ >
+
+ Reset
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/scenario/ScenarioFilters.tsx b/src/components/scenario/ScenarioFilters.tsx
index aae068ac..9f05733c 100644
--- a/src/components/scenario/ScenarioFilters.tsx
+++ b/src/components/scenario/ScenarioFilters.tsx
@@ -1,28 +1,26 @@
import type { ScenarioRecordFilterInput } from '@/__generated__/graphql';
-import type { ReactElement } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useSearchParams } from 'react-router';
const getQueueTypeFilters = (
search: URLSearchParams,
-): { queueType?: number; premadeOnly: boolean } => {
+): { queueType?: number } => {
const queueType = search.get('queue_type');
- const premadeOnly = search.get('premadeOnly') === 'true';
switch (queueType) {
- case 'standard':
- return { queueType: 0, premadeOnly };
- case 'group_ranked':
- return { queueType: 1, premadeOnly };
- case 'solo':
- return { queueType: 2, premadeOnly };
- case 'city_siege':
- return { queueType: 3, premadeOnly };
- case 'solo_ranked':
- return { queueType: 4, premadeOnly };
+ case 'standard': {
+ return { queueType: 0 };
+ }
+ case 'solo': {
+ return { queueType: 2 };
+ }
+ case 'city_siege': {
+ return { queueType: 4 };
+ }
+ case 'group_challenge': {
+ return { queueType: 6 };
+ }
}
- return { premadeOnly };
+ return {};
};
const getTierFilters = (search: URLSearchParams): ScenarioRecordFilterInput => {
@@ -45,6 +43,87 @@ const getTierFilters = (search: URLSearchParams): ScenarioRecordFilterInput => {
return {};
};
+// A 'from'/'to' filter value can either be a plain YYYY-MM-DD date (from the
+// date picker inputs) or a full ISO timestamp. Shared links bake in the
+// latter so a shared link always reproduces the same absolute time window,
+// instead of drifting when a relative range is reinterpreted later.
+export const parseFilterDate = (value: string, endOfDay: boolean): Date =>
+ value.includes('T')
+ ? new Date(value)
+ : new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}`);
+
+const getTimeFilters = (
+ search: URLSearchParams,
+ defaultRange: '1h' | 'recent',
+): ScenarioRecordFilterInput => {
+ const range = search.get('range') ?? defaultRange;
+ // Round "now" down to a 5-minute bucket instead of using the exact
+ // current time. GetTimeFilters recomputes on every mount (e.g. leaving
+ // /scenarios to view a character, then coming back), and Apollo's cache
+ // only matches identical query variables — even a few seconds' drift in
+ // "now" would produce a different startTime.gte on each mount and force
+ // a full refetch instead of reusing the cache. Bucketing keeps the same
+ // variables (and cache hit) for repeat visits within the same 5-minute
+ // window. The explicit Refresh button still bypasses the cache entirely.
+ const now = new Date();
+ now.setMinutes(Math.floor(now.getMinutes() / 5) * 5, 0, 0);
+ let start: Date | undefined;
+ let end: Date | undefined;
+
+ switch (range) {
+ case '1h': {
+ start = new Date(now.getTime() - 60 * 60 * 1000);
+ break;
+ }
+ case '24h': {
+ start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '7d': {
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '30d': {
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '90d': {
+ start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case 'ytd': {
+ start = new Date(now.getFullYear(), 0, 1);
+ break;
+ }
+ case 'custom': {
+ const startValue = search.get('from');
+ const endValue = search.get('to');
+ if (startValue) {
+ start = parseFilterDate(startValue, false);
+ }
+ if (endValue) {
+ end = parseFilterDate(endValue, true);
+ }
+ break;
+ }
+ }
+
+ if (!start && !end) {
+ return {};
+ }
+
+ return {
+ startTime: {
+ ...(start && !Number.isNaN(start.getTime())
+ ? { gte: start.toISOString() }
+ : {}),
+ ...(end && !Number.isNaN(end.getTime())
+ ? { lte: end.toISOString() }
+ : {}),
+ },
+ };
+};
+
export const getScenarioFilters = (
search: URLSearchParams,
{
@@ -53,9 +132,11 @@ export const getScenarioFilters = (
wins,
}: { characterId?: string; guildId?: string; wins?: boolean } = {},
): ScenarioRecordFilterInput => {
- const { queueType, premadeOnly } = getQueueTypeFilters(search);
+ const { queueType } = getQueueTypeFilters(search);
+ const defaultRange = characterId || guildId ? 'recent' : '1h';
const where: ScenarioRecordFilterInput = {
...getTierFilters(search),
+ ...getTimeFilters(search, defaultRange),
};
if (queueType !== undefined) {
@@ -63,125 +144,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 (Object.keys(scoreboardEntry).length > 0) {
where.scoreboardEntries = { some: scoreboardEntry };
}
return where;
};
-
-export const ScenarioFilters = ({
- showPremadeOnly = false,
-}: {
- showPremadeOnly?: boolean;
-}): ReactElement => {
- const { t } = useTranslation('components');
- const [search, setSearch] = useSearchParams();
-
- const queueType = search.get('queue_type') || 'all';
-
- return (
-
-
-
-
-
-
-
- {t('scenarioFilters.queueType')}
-
-
-
-
-
- {
- search.set('queue_type', event.target.value);
- setSearch(search);
- }}
- >
-
- {t('scenarioFilters.queueTypeAll')}
-
-
- {t('scenarioFilters.queueTypeStandard')}
-
-
- {t('scenarioFilters.queueTypeSolo')}
-
-
- {t('scenarioFilters.queueTypeCitySiege')}
-
-
- {t('scenarioFilters.queueTypeGroupRanked')}
-
-
- {t('scenarioFilters.queueTypeSoloRanked')}
-
-
-
-
-
-
-
-
-
-
-
- {t('scenarioFilters.tier')}
-
-
-
-
-
- {
- search.set('tier', event.target.value);
- setSearch(search);
- }}
- >
-
- {t('scenarioFilters.tierAll')}
-
- 1
- 3
- 4
-
-
-
-
-
-
- {showPremadeOnly && (
-
-
- {
- if (event.target.checked) {
- search.set('premadeOnly', 'true');
- } else {
- search.delete('premadeOnly');
- }
- setSearch(search);
- }}
- />{' '}
- {t('scenarioFilters.premadeOnly')}
-
-
- )}
-
-
-
- );
-};
diff --git a/src/components/scenario/ScenarioList.tsx b/src/components/scenario/ScenarioList.tsx
index 608ef41d..04871b46 100644
--- a/src/components/scenario/ScenarioList.tsx
+++ b/src/components/scenario/ScenarioList.tsx
@@ -1,12 +1,19 @@
import { gql } from '@apollo/client';
-import { useQuery } from '@apollo/client/react';
+import { useApolloClient, useQuery } from '@apollo/client/react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router';
-import type { Query } from '@/__generated__/graphql';
+import { format } from 'date-fns';
+import { useEffect, useRef, useState } from 'react';
+import type { Query, ScenarioRecord } from '@/__generated__/graphql';
import { ErrorMessage } from '@/components/global/ErrorMessage';
-import { getScenarioFilters } from '@/components/scenario/ScenarioFilters';
+import {
+ getScenarioFilters,
+ parseFilterDate,
+} from '@/components/scenario/ScenarioFilters';
import { ScenarioListTable } from '@/components/scenario/ScenarioListTable';
import { QueryPagination } from '@/components/global/QueryPagination';
+import { ScenarioStandouts } from '@/components/scenario/ScenarioStandouts';
+import { CharacterScenarioConnections } from '@/components/scenario/CharacterScenarioConnections';
const SCENARIO_LIST = gql`
query GetScenarioList(
@@ -36,6 +43,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,42 +82,472 @@ 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 isProfileHistory = Boolean(characterId || guildId);
+ const range = search.get('range') ?? (isProfileHistory ? 'recent' : '1h');
+ const isFullWindow = range !== 'recent';
+ const [resultLimit, setResultLimit] = useState(perPage);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [windowScenarios, setWindowScenarios] = useState([]);
+ const [windowTotal, setWindowTotal] = useState(0);
+ const [windowLoading, setWindowLoading] = useState(false);
+ const [windowError, setWindowError] = useState();
+ const [windowHasDataIssue, setWindowHasDataIssue] = useState(false);
+ const [reloadToken, setReloadToken] = useState(0);
+ // Leaving this page (e.g. clicking into a character) unmounts ScenarioList,
+ // so windowScenarios is lost and the batch-load below runs again on
+ // remount. Apollo's InMemoryCache survives that unmount (it lives on the
+ // client, not the component), so defaulting to 'cache-first' lets a
+ // return visit reuse what was already fetched instead of redownloading
+ // the whole window. The explicit Refresh button sets this ref to force a
+ // real network fetch for that one pass.
+ const forceNetworkRefresh = useRef(false);
+ const dataFilterKey = ['queue_type', 'tier', 'range', 'from', 'to']
+ .map((key) => `${key}=${search.get(key) ?? ''}`)
+ .concat([`character=${characterId ?? ''}`, `guild=${guildId ?? ''}`])
+ .join('&');
+ const client = useApolloClient();
+ const where = getScenarioFilters(search, { characterId, guildId });
+
+ useEffect(() => {
+ setResultLimit(perPage);
+ }, [dataFilterKey, perPage]);
- const { loading, error, data, refetch } = useQuery(SCENARIO_LIST, {
+ const {
+ loading: recentLoading,
+ error: recentError,
+ data,
+ refetch,
+ } = useQuery(SCENARIO_LIST, {
+ skip: isFullWindow,
variables: {
first: perPage,
- where: getScenarioFilters(search, { characterId, guildId }),
+ where,
},
});
- if (loading) {
+ useEffect(() => {
+ if (!isFullWindow) {
+ setWindowScenarios([]);
+ setWindowTotal(0);
+ setWindowLoading(false);
+ setWindowError(undefined);
+ return;
+ }
+
+ let cancelled = false;
+ let windowTotalCount: number | undefined;
+ const controller = new AbortController();
+
+ // Cursors from this API are base64-encoded zero-based offsets, so an
+ // arbitrary [start, start + count) window can be requested directly.
+ const encodeOffset = (offset: number): string | undefined =>
+ offset > 0 ? btoa(String(offset - 1)) : undefined;
+
+ // A single malformed row nulls out the whole array it's in under
+ // GraphQL's non-null propagation rules. Rather than losing an entire
+ // 50-item page (or failing the whole window) when that happens, split
+ // the failing range in half and retry each half until the bad row(s)
+ // are isolated.
+ const fetchRange = async (
+ useNetworkOnly: boolean,
+ start: number,
+ count: number,
+ ): Promise => {
+ if (count <= 0) {
+ return [];
+ }
+ const result = await client.query({
+ context: { fetchOptions: { signal: controller.signal } },
+ errorPolicy: 'all',
+ fetchPolicy: useNetworkOnly ? 'network-only' : 'cache-first',
+ query: SCENARIO_LIST,
+ variables: { after: encodeOffset(start), first: count, where },
+ });
+ const connection = result.data?.scenarios;
+ if (!connection) {
+ return [];
+ }
+ if (windowTotalCount === undefined) {
+ windowTotalCount = connection.totalCount;
+ if (!cancelled) {
+ setWindowTotal(connection.totalCount);
+ }
+ }
+ if (connection.nodes) {
+ return connection.nodes;
+ }
+ if (count === 1) {
+ if (!cancelled) {
+ setWindowHasDataIssue(true);
+ }
+ return [];
+ }
+ const half = Math.ceil(count / 2);
+ const left = await fetchRange(useNetworkOnly, start, half);
+ const right = await fetchRange(
+ useNetworkOnly,
+ start + half,
+ count - half,
+ );
+ return [...left, ...right];
+ };
+
+ const loadWindow = async (): Promise => {
+ // Only a real click on Refresh forces the network; capture and
+ // consume that intent once per load so later reloads (e.g. a plain
+ // remount) go back to preferring the cache.
+ const useNetworkOnly = forceNetworkRefresh.current;
+ forceNetworkRefresh.current = false;
+ setWindowScenarios([]);
+ setWindowTotal(0);
+ setWindowError(undefined);
+ setWindowHasDataIssue(false);
+ setWindowLoading(true);
+ let offset = 0;
+ const accumulated: ScenarioRecord[] = [];
+
+ try {
+ do {
+ const nodes = await fetchRange(useNetworkOnly, offset, 50);
+ accumulated.push(...nodes);
+ offset += 50;
+ if (!cancelled) {
+ setWindowScenarios([...accumulated]);
+ }
+ if (windowTotalCount === undefined || offset >= windowTotalCount) {
+ break;
+ }
+ } while (!cancelled);
+ } catch (caughtError) {
+ if (!cancelled && !controller.signal.aborted) {
+ setWindowError(
+ caughtError instanceof Error
+ ? caughtError
+ : new Error('Unable to load the selected time window.'),
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setWindowLoading(false);
+ }
+ }
+ };
+
+ void loadWindow();
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ }, [client, dataFilterKey, isFullWindow, reloadToken]);
+
+ const loading = isFullWindow ? windowLoading : recentLoading;
+ const error = isFullWindow ? windowError : recentError;
+ const scenarios = isFullWindow
+ ? windowScenarios
+ : (data?.scenarios?.nodes ?? []);
+ const pageInfo = data?.scenarios?.pageInfo;
+
+ if (loading && scenarios.length === 0) {
+ return (
+ <>
+ {characterId ? (
+
+ ) : (
+
+ )}
+
+
+
Gathering the complete selected time window…
+
+ Player scoreboards are loaded in batches so the final leaderboard is
+ exact.
+
+
+ >
+ );
+ }
+ if (recentLoading) {
return ;
}
if (error) {
return ;
}
- if (data?.scenarios?.nodes == null) {
+ if (!isFullWindow && data?.scenarios?.nodes == null) {
return ;
}
- const pageInfo = data?.scenarios?.pageInfo;
+ if (scenarios.length === 0) {
+ return (
+ <>
+ {characterId ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+ }
+ 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;
+ const rangeLabels: Record = {
+ recent: 'most recent matches',
+ '1h': 'matches · complete last hour',
+ '24h': 'matches · complete last 24 hours',
+ '7d': 'matches · complete last 7 days',
+ '30d': 'matches · complete last 30 days',
+ '90d': 'matches · complete last 90 days',
+ ytd: 'matches · complete year to date',
+ custom: 'matches · complete custom dates',
+ };
+ const selectedWindowLabel = (() => {
+ if (!isFullWindow) {
+ return undefined;
+ }
+ const now = new Date();
+ now.setSeconds(0, 0);
+ let start: Date | undefined;
+ let end = now;
+ switch (range) {
+ case '1h':
+ start = new Date(now.getTime() - 60 * 60 * 1000);
+ break;
+ case '24h':
+ start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ break;
+ case '7d':
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ case '30d':
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ case '90d':
+ start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ break;
+ case 'ytd':
+ start = new Date(now.getFullYear(), 0, 1);
+ break;
+ case 'custom': {
+ const from = search.get('from');
+ const to = search.get('to');
+ start = from ? parseFilterDate(from, false) : undefined;
+ end = to ? parseFilterDate(to, true) : now;
+ break;
+ }
+ }
+ if (
+ !start ||
+ Number.isNaN(start.getTime()) ||
+ Number.isNaN(end.getTime())
+ ) {
+ return undefined;
+ }
+ return `${format(start, 'MMM d, h:mm a')} – ${format(
+ end,
+ 'MMM d, h:mm a',
+ )}`;
+ })();
+ const visibleScenarios =
+ loadMore && isFullWindow ? scenarios.slice(0, resultLimit) : scenarios;
return (
<>
-
-
+
+
+
+ {selectedWindowLabel
+ ? 'Selected scenario window'
+ : t('components:scenarioList.recentActivity')}
+
+
+ {selectedWindowLabel && `${selectedWindowLabel} · match activity `}
+ {format(earliestScenarioDate, 'MMM d, h:mm a')} –{' '}
+ {format(latestScenarioDate, 'MMM d, h:mm a')}
+ {loading &&
+ ` · gathering ${scenarios.length} of ${windowTotal || '…'}`}
+
+
+
{
+ if (isFullWindow) {
+ forceNetworkRefresh.current = true;
+ setReloadToken((current) => current + 1);
+ } else {
+ void refetch();
+ }
+ }}
+ >
+
+
+
+ {t('components:scenarioList.refresh')}
+
+
+
+
+ {scenarios.length}
+
+ {loading
+ ? `gathering ${scenarios.length} of ${windowTotal || '…'}`
+ : (rangeLabels[range] ?? 'matches')}
+
+
+
+ {averagePlayers.toFixed(1)}
+ {t('components:scenarioList.averagePlayers')}
+
+
+
+ {Math.floor(averageDurationSeconds / 60)}m{' '}
+ {Math.round(averageDurationSeconds % 60)}s
+
+ {t('components:scenarioList.averageDuration')}
+
+
+
+
+
+ {orderWins.toLocaleString()} Order wins
+
+
+ {destructionWins.toLocaleString()} Destruction wins
+
+
+
+
+
+
+ {orderWinPercentage.toFixed(1)}%
+
+
+ {(100 - orderWinPercentage).toFixed(1)}%
+
+
+
+
+ {loading && (
+
+
+ Calculating the full {range === 'ytd' ? 'year-to-date' : range}{' '}
+ leaderboard: {scenarios.length}
+ {windowTotal ? ` of ${windowTotal}` : ''} matches gathered.
+
+ )}
+ {isFullWindow && windowHasDataIssue && (
+
+ Some matches could not be loaded due to a data issue and are missing
+ from these results.
+
+ )}
+ {characterId ? (
+
+ ) : (
+
+ )}
+
+ {loadMore ? (
+ ((isFullWindow && resultLimit < scenarios.length) ||
+ (!isFullWindow && pageInfo?.hasNextPage)) && (
+
+ {
+ const nextLimit = resultLimit + perPage;
+ if (isFullWindow) {
+ setResultLimit(nextLimit);
+ return;
+ }
+ setLoadingMore(true);
+ try {
+ await refetch({
+ first: nextLimit,
+ where: getScenarioFilters(search, { characterId, guildId }),
+ });
+ setResultLimit(nextLimit);
+ } finally {
+ setLoadingMore(false);
+ }
+ }}
+ >
+ {t('components:scenarioList.showMore')}
+
+
+ )
+ ) : !isFullWindow && pageInfo ? (
+
+ ) : null}
>
);
};
diff --git a/src/components/scenario/ScenarioListTable.tsx b/src/components/scenario/ScenarioListTable.tsx
index 37901c22..95dce79c 100644
--- a/src/components/scenario/ScenarioListTable.tsx
+++ b/src/components/scenario/ScenarioListTable.tsx
@@ -1,157 +1,125 @@
-import { format, formatISO, intervalToDuration } from 'date-fns';
-import { useTranslation } from 'react-i18next';
+import { format, formatDuration, intervalToDuration } from 'date-fns';
import { Link } from 'react-router';
-import useWindowDimensions from '@/hooks/useWindowDimensions';
+import { type ReactElement, useState } from 'react';
import type { ScenarioRecord } from '@/__generated__/graphql';
-import type { ReactElement } from 'react';
-import clsx from 'clsx';
+import { ScenarioRosterPreview } from '@/components/scenario/ScenarioRosterPreview';
+import { assetUrl } from '@/utils';
+
+const RealmScore = ({
+ isWinner,
+ name,
+ points,
+ realm,
+ surrendered,
+}: {
+ isWinner: boolean;
+ name: string;
+ points: number | null;
+ realm: 'order' | 'destruction';
+ surrendered: boolean;
+}): ReactElement => (
+
+
+
+ {name}
+ {points ?? 0}
+
+ {surrendered && (
+
+ )}
+
+);
export const ScenarioListTable = ({
data,
}: {
data: ScenarioRecord[];
}): ReactElement => {
- const { width } = useWindowDimensions();
- const isMobile = width <= 768;
-
- const { t } = useTranslation(['common', 'components']);
+ const [expandedScenarioId, setExpandedScenarioId] = useState();
return (
-
-
-
-
- {t('components:scenarioList.name')}
- {t('components:scenarioList.tier')}
- {t('components:scenarioList.time')}
- {t('components:scenarioList.duration')}
- {t('components:scenarioList.winner')}
-
- {t('components:scenarioList.order')}
-
-
-
- {t('components:scenarioList.destruction')}
-
-
-
-
-
-
- {data.map((scenario) => {
- const startDate = new Date(scenario.startTime);
- const endDate = new Date(scenario.endTime);
- const duration = intervalToDuration({
- end: endDate,
- start: startDate,
- });
+
+ {data.map((scenario) => {
+ const startDate = new Date(scenario.startTime);
+ const endDate = new Date(scenario.endTime);
+ const isExpanded = expandedScenarioId === scenario.id;
+ const duration = formatDuration(
+ intervalToDuration({ end: endDate, start: startDate }),
+ { format: ['hours', 'minutes', 'seconds'] },
+ );
+ const orderWon = scenario.points[0] > scenario.points[1];
- return (
-
- {scenario.scenario.name}
- {scenario.tier}
-
- {' '}
-
- {formatISO(startDate, { representation: 'date' })}
-
- {format(startDate, 'HH:mm:ss')}
-
-
-
- {t(
- duration.hours
- ? 'components:scenarioList.scenarioDurationHour'
- : 'components:scenarioList.scenarioDuration',
- {
- hours: duration.hours,
- minutes: duration.minutes,
- seconds: duration.seconds,
- },
- )}
-
-
- {scenario.points[0] > scenario.points[1] ? (
-
- ) : (
-
- )}
-
-
- {scenario.points[0]}
- {scenario.wasSurrender &&
- scenario.points[1] > scenario.points[0] ? (
-
- ) : (
-
- )}
-
-
- {scenario.points[1]}
- {scenario.wasSurrender &&
- scenario.points[0] > scenario.points[1] ? (
-
- ) : (
-
- )}
-
-
-
- {t('components:scenarioList.details')}
-
-
-
- );
- })}
-
-
+ return (
+
+
+
+ {scenario.scenario.name}
+
+ {format(startDate, 'MMM d, yyyy · h:mm a')} · {duration}
+
+
+
+ {scenario.numPlayers} players
+ {Number(scenario.numDeaths)} deaths
+
+
+
+
+
+
+ {
+ setExpandedScenarioId(isExpanded ? undefined : scenario.id);
+ }}
+ >
+ {isExpanded ? 'Hide players' : 'Show players'}
+
+
+ Full details
+
+
+
+ {isExpanded && (
+
+
+
+ )}
+
+ );
+ })}
);
};
diff --git a/src/components/scenario/ScenarioRosterPreview.tsx b/src/components/scenario/ScenarioRosterPreview.tsx
new file mode 100644
index 00000000..cee9d39f
--- /dev/null
+++ b/src/components/scenario/ScenarioRosterPreview.tsx
@@ -0,0 +1,182 @@
+import { Link } from 'react-router';
+import { Fragment, type ReactElement } from 'react';
+import type { ScenarioScoreboardEntry } from '@/__generated__/graphql';
+import { CareerIcon } from '@/components/CareerIcon';
+import {
+ scenarioCareerName,
+ scenarioCareerRoles,
+ scenarioRoleOrder,
+} from '@/components/scenario/scenarioRoles';
+import { assetUrl } from '@/utils';
+
+const formatNumber = (value: number): string =>
+ new Intl.NumberFormat('en', { notation: 'compact' }).format(value);
+
+const RealmRoster = ({
+ entries,
+ name,
+ realm,
+}: {
+ entries: ScenarioScoreboardEntry[];
+ name: string;
+ realm: 'order' | 'destruction';
+}): ReactElement => {
+ const totals = entries.reduce(
+ (result, entry) => ({
+ damage: result.damage + entry.damage,
+ healing: result.healing + entry.healing,
+ kills: result.kills + entry.kills,
+ protection: result.protection + entry.protection,
+ }),
+ { damage: 0, healing: 0, kills: 0, protection: 0 },
+ );
+ const roleGroups = scenarioRoleOrder.map((role) => ({
+ entries: entries.filter(
+ (entry) => scenarioCareerRoles[entry.character.career] === role,
+ ),
+ role,
+ }));
+
+ return (
+
+
+
+
+ {name}
+ {entries.length} players
+
+
+
+
+ {roleGroups.map(({ entries: roleEntries, role }) => (
+
+ {roleEntries.length} {role}
+
+ ))}
+
+
+
+ {totals.kills} Kills
+
+
+ {formatNumber(totals.damage)} Damage
+
+
+ {formatNumber(totals.healing)} Healing
+
+
+ {formatNumber(totals.protection)} Protection
+
+
+
+
+
+
+
+ Character
+ K
+ D
+ DB
+ Dmg
+ KDmg
+ Heals
+ Prot
+ Obj
+
+
+ {roleGroups.map(({ entries: roleEntries, role }) =>
+ roleEntries.length > 0 ? (
+
+
+
+
+ {role} ({roleEntries.length})
+
+ {[
+ ...new Set(
+ roleEntries.map(
+ (entry) => entry.character.career,
+ ),
+ ),
+ ]
+ .map(
+ (career) =>
+ `${scenarioCareerName(career)} ×${
+ roleEntries.filter(
+ (entry) =>
+ entry.character.career === career,
+ ).length
+ }`,
+ )
+ .join(', ')}
+
+
+
+
+
+ {roleEntries.map((entry) => (
+
+
+
+
+
+
+ {entry.character.name}
+
+
+ CR {entry.level} · RR {entry.renownRank}
+
+ {entry.guild && (
+
+
+ {entry.guild.name}
+
+
+ )}
+
+ {entry.kills}
+ {entry.deaths}
+ {entry.deathBlows}
+ {formatNumber(entry.damage)}
+ {formatNumber(entry.killDamage)}
+ {formatNumber(entry.healing)}
+ {formatNumber(entry.protection)}
+
+ {formatNumber(entry.objectiveScore)}
+
+
+ ))}
+
+
+ ) : null,
+ )}
+
+
+
+
+ );
+};
+
+export const ScenarioRosterPreview = ({
+ entries,
+}: {
+ entries: ScenarioScoreboardEntry[];
+}): ReactElement => (
+
+ entry.team === 0)}
+ name="Order"
+ realm="order"
+ />
+ entry.team === 1)}
+ name="Destruction"
+ realm="destruction"
+ />
+
+);
diff --git a/src/components/scenario/ScenarioScoreboard.tsx b/src/components/scenario/ScenarioScoreboard.tsx
index 5526fdc2..79049017 100644
--- a/src/components/scenario/ScenarioScoreboard.tsx
+++ b/src/components/scenario/ScenarioScoreboard.tsx
@@ -1,237 +1,310 @@
import { Link } from 'react-router';
import Tippy from '@tippyjs/react';
+import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ScenarioScoreboardEntryFragment } from '@/__generated__/graphql';
import { CareerIcon } from '@/components/CareerIcon';
import { GuildHeraldry } from '@/components/guild/GuildHeraldry';
-import { useSortableData } from '@/hooks/useSortableData';
+import {
+ scenarioCareerRoles,
+ scenarioRoleOrder,
+} from '@/components/scenario/scenarioRoles';
+import { assetUrl } from '@/utils';
import type { ReactElement } from 'react';
-export const ScenarioScoreboard = ({
+type StatKey =
+ | 'kills'
+ | 'deaths'
+ | 'deathBlows'
+ | 'damage'
+ | 'killDamage'
+ | 'healing'
+ | 'protection'
+ | 'objectiveScore';
+
+const STAT_KEYS: StatKey[] = [
+ 'kills',
+ 'deaths',
+ 'deathBlows',
+ 'damage',
+ 'killDamage',
+ 'healing',
+ 'protection',
+ 'objectiveScore',
+];
+
+// Percentage of the entry's own team total for this stat. Showing this next
+// to the raw number lets you compare a healer's contribution to a DPS's
+// contribution without inventing a combined score across different roles.
+const shareOfTeam = (value: number, total: number): string =>
+ total > 0 ? `${Math.round((value / total) * 100)}%` : '—';
+
+const StatCell = ({
+ total,
+ tooltip,
+ value,
+}: {
+ total: number;
+ tooltip?: ReactElement;
+ value: number;
+}): ReactElement => {
+ const inner = (
+
+ {value.toLocaleString()}{' '}
+
+ ({shareOfTeam(value, total)})
+
+
+ );
+ return (
+
+ {tooltip ? (
+
+ {inner}
+
+ ) : (
+ inner
+ )}
+
+ );
+};
+
+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 (
-
-
-
-
- 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')}
-
-
-
-
- {items.map((entry: ScenarioScoreboardEntryFragment) => (
-
-
-
-
-
-
- {entry.character.name}
-
-
-
- {entry.guild && (
-
-
-
- )}
-
-
- {entry.guild && (
-
- {entry.guild.name}
-
- )}
-
- {entry.level}
+
+
+
+ {realm === 'order' ? 'Order' : 'Destruction'}
+ {entries.length} players
+
+
+ {STAT_KEYS.map((key) => (
+
+ {totals[key].toLocaleString()}
+ {statLabels[key]}
+
+ ))}
+
+ {roleGroups.map(({ role, roleEntries }) => (
+
+
+
+
+
+ {role} ({roleEntries.length})
+
+
+
+
+ {t('components:scenarioScoreboard.name')}
+ {t('components:scenarioScoreboard.guild')}
+ {STAT_KEYS.map((key) => (
+ {
+ onSort(key);
+ }}
+ >
+ {statLabels[key]}
+ {sortKey === key && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
+
+ ))}
+
+
+
+ {roleEntries.map((entry) => (
+
+
+
+
+
+
+ {entry.character.name}
+
+
+ CR {entry.level} · RR {entry.renownRank}
+
+
+
+ {entry.guild && (
+
+
+
+ )}
+
+
+ {entry.guild && (
+
+ {entry.guild.name}
+
+ )}
+
+
+ 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}
+
+ }
+ />
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
+
+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 => (
+ {
+ if (sortKey === key) {
+ setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortKey(key);
+ setSortDirection(key === 'name' ? 'asc' : 'desc');
+ }
+ }}
+ >
+ {label}
+ {sortKey === key && (
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+
+
+ {sortHeading('Scenario', 'name')}
+ {sortHeading('Matches', 'matches')}
+ {sortHeading('Order', 'order')}
+ {sortHeading('Destruction', 'destruction')}
+ {sortHeading('Avg. players', 'players')}
+ {sortHeading('Avg. duration', 'duration')}
+
+ Top killer
+
+
+ {sortHeading('Balance', 'balance')}
+
+
+
+
+ {visibleBreakdown.map((entry) => {
+ const completedMatches = entry.orderWins + entry.destructionWins;
+ const orderRate =
+ completedMatches > 0
+ ? Math.round((entry.orderWins / completedMatches) * 100)
+ : 0;
+ const destructionRate =
+ completedMatches > 0
+ ? Math.round((entry.destructionWins / completedMatches) * 100)
+ : 0;
+ const isExpanded = expandedScenarioId === entry.id;
+
+ return (
+ {
+ setExpandedScenarioId(isExpanded ? undefined : entry.id);
+ }}
+ />
+ );
+ })}
+ {visibleBreakdown.length === 0 && (
+
+
+ No scenarios meet the selected minimum.
+
+
+ )}
+
+
+
+
+ );
+};
+
+const ScenarioBreakdownRows = ({
+ destructionRate,
+ entry,
+ isExpanded,
+ onToggle,
+ orderRate,
+}: {
+ destructionRate: number;
+ entry: ScenarioBreakdownEntry;
+ isExpanded: boolean;
+ onToggle: () => void;
+ orderRate: number;
+}): ReactElement => {
+ const durationMinutes = Math.floor(entry.averageDurationSeconds / 60);
+ const durationSeconds = Math.round(entry.averageDurationSeconds % 60);
+ const balanceDifference = Math.abs(orderRate - 50);
+ const balanceStatus =
+ balanceDifference <= 5
+ ? 'Balanced'
+ : balanceDifference <= 10
+ ? 'Watch'
+ : 'Lopsided';
+ const balanceClass =
+ balanceDifference <= 5
+ ? 'balanced'
+ : balanceDifference <= 10
+ ? 'watch'
+ : 'lopsided';
+
+ return (
+ <>
+
+
+
+
+ {entry.name}
+
+
+ {entry.matches.length}
+
+ {orderRate}% ({entry.orderWins})
+
+
+ {destructionRate}% ({entry.destructionWins})
+
+ {entry.averagePlayers.toFixed(1)}
+
+ {durationMinutes}m {durationSeconds}s
+
+
+ {entry.topPlayer ? (
+
+ {entry.topPlayer.name}
+
+ ) : (
+ '—'
+ )}
+
+
+
+ {balanceStatus}
+
+
+
+ {isExpanded && (
+
+
+
+ {entry.matches.slice(0, 50).map((match) => (
+
+
+ {new Date(match.startTime).toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })}
+
+
+ {match.winner === 0
+ ? 'Order'
+ : match.winner === 1
+ ? 'Destruction'
+ : 'Draw'}
+
+
+
+ {match.points[0]}
+
+ {' – '}
+
+ {match.points[1]}
+
+
+
{match.numPlayers} players
+
+ ))}
+ {entry.matches.length > 50 && (
+
+ Showing the 50 most recent of {entry.matches.length} matches.
+
+ )}
+
+
+
+ )}
+ >
+ );
+};
+
+const StandoutTable = ({
+ career,
+ limit,
+ metric,
+ minimumScenarios,
+ mode,
+ realm,
+ role,
+ scenarios,
+ sortable = false,
+ team,
+ onExpand,
+ onModeChange,
+ onSelectPlayer,
+}: {
+ career: CareerFilter;
+ limit: number;
+ metric: RankingMetric;
+ minimumScenarios: number;
+ mode: RankingMode;
+ realm: 'Order' | 'Destruction';
+ role: RoleFilter;
+ scenarios: ScenarioRecord[];
+ sortable?: boolean;
+ team: number;
+ onExpand?: () => void;
+ onModeChange?: (mode: RankingMode) => void;
+ onSelectPlayer?: (standout: Standout, realm: 'Order' | 'Destruction') => void;
+}): ReactElement => {
+ const [sortKey, setSortKey] = useState(metric);
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
+ const standouts = getStandouts({
+ career,
+ limit,
+ metric,
+ minimumScenarios,
+ mode,
+ role,
+ scenarios,
+ team,
+ });
+ const sortableValue = (standout: Standout, key: TableSortKey): number => {
+ const value = standout[key];
+ if (contributionMetrics.includes(key as ContributionMetric)) {
+ if (mode === 'average') {
+ return Number(value) / standout.scenarios;
+ }
+ if (mode === 'median') {
+ return standout.medians[key as ContributionMetric];
+ }
+ }
+ return typeof value === 'number' ? value : 0;
+ };
+ const sortedStandouts = sortable
+ ? standouts.toSorted((left, right) => {
+ let result = 0;
+ if (sortKey === 'name') {
+ result = left.name.localeCompare(right.name);
+ } else if (sortKey === 'career') {
+ result = scenarioCareerName(left.career).localeCompare(
+ scenarioCareerName(right.career),
+ );
+ } else {
+ result = sortableValue(left, sortKey) - sortableValue(right, sortKey);
+ }
+
+ return (
+ (sortDirection === 'asc' ? result : -result) ||
+ left.name.localeCompare(right.name)
+ );
+ })
+ : standouts;
+ const wins = scenarios.filter((scenario) => scenario.winner === team).length;
+ const contributionValue = (
+ standout: Standout,
+ key: ContributionMetric,
+ ): number =>
+ mode === 'average'
+ ? standout[key] / standout.scenarios
+ : mode === 'median'
+ ? standout.medians[key]
+ : standout[key];
+ const sortBy = (key: TableSortKey): void => {
+ if (key === sortKey) {
+ setSortDirection((current) => (current === 'desc' ? 'asc' : 'desc'));
+ } else {
+ setSortKey(key);
+ setSortDirection(key === 'name' || key === 'career' ? 'asc' : 'desc');
+ }
+ };
+ const heading = (
+ label: string,
+ key: TableSortKey,
+ ariaLabel = label,
+ ): ReactElement =>
+ sortable ? (
+ {
+ sortBy(key);
+ }}
+ >
+ {label}
+ {sortKey === key && (
+
+ )}
+
+ ) : (
+ <>{label}>
+ );
+
+ return (
+
+
+
+
+ {realm} Standouts
+
+ {scenarios.length} scenarios · {wins} wins ·{' '}
+ {scenarios.length - wins} losses · ranked by {metricLabels[metric]}
+ {mode === 'average'
+ ? ' per scenario'
+ : mode === 'median'
+ ? ' by median'
+ : ''}
+
+
+ {onModeChange && (
+
+ View
+
+ {
+ onModeChange(event.target.value as RankingMode);
+ }}
+ >
+ Totals
+ Average
+ Median
+
+
+
+ )}
+ {onExpand && (
+
+
+
+
+
+ )}
+
+ {standouts.length === 0 ? (
+
+ No matching characters in this scenario window.
+
+ ) : (
+
+
+
+
+ {heading('', 'career', 'career')}
+ {heading('Character', 'name')}
+ {heading('SC', 'scenarios', 'scenarios')}
+ {heading('W', 'wins', 'wins')}
+ {heading('WR', 'winRate', 'win rate')}
+ {heading('Kills', 'kills')}
+
+ {heading('DB', 'deathBlows', 'death blows')}
+
+ {heading('Dmg', 'damage', 'damage')}
+
+ {heading('KDmg', 'killDamage', 'kill damage')}
+
+ {heading('Heals', 'healing', 'healing')}
+
+ {heading('Prot', 'protection', 'protection')}
+
+
+ {heading('Obj', 'objectiveScore', 'objective score')}
+
+
+
+
+ {sortedStandouts.map((standout) => (
+
+
+
+
+
+ {
+ onSelectPlayer?.(standout, realm);
+ }}
+ >
+ {standout.name}
+
+
+ {standout.scenarios}
+ {standout.wins}
+ {Math.round(standout.winRate * 100)}%
+
+ {compactNumber(contributionValue(standout, 'kills'))}
+
+
+ {compactNumber(contributionValue(standout, 'deathBlows'))}
+
+
+ {compactNumber(contributionValue(standout, 'damage'))}
+
+
+ {compactNumber(contributionValue(standout, 'killDamage'))}
+
+
+ {compactNumber(contributionValue(standout, 'healing'))}
+
+
+ {compactNumber(contributionValue(standout, 'protection'))}
+
+
+ {compactNumber(
+ contributionValue(standout, 'objectiveScore'),
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export const ScenarioStandouts = ({
+ defaultRange = '1h',
+ scenarios,
+}: {
+ defaultRange?: '1h' | 'recent';
+ scenarios: ScenarioRecord[];
+}): ReactElement => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const realmParam = searchParams.get('lbRealm') as RealmFilter;
+ const roleParam = searchParams.get('lbRole') as RoleFilter;
+ const careerParam = searchParams.get('lbCareer') as CareerFilter;
+ const metricParam = searchParams.get('lbMetric') as RankingMetric;
+ const limitParam = Number(searchParams.get('lbLimit'));
+ const minimumScenariosParam = Number(searchParams.get('lbMin'));
+ const modeParam = searchParams.get('lbMode') as RankingMode;
+ const queueType = searchParams.get('queue_type') ?? 'all';
+ const tier = searchParams.get('tier') ?? 'all';
+ const range = searchParams.get('range') ?? defaultRange;
+ const realm = realmFilters.includes(realmParam) ? realmParam : 'both';
+ const role = roleFilters.includes(roleParam) ? roleParam : 'all';
+ const career =
+ careerParam === 'all' || Object.values(Career).includes(careerParam)
+ ? careerParam
+ : 'all';
+ const metric = rankingMetrics.includes(metricParam) ? metricParam : 'winRate';
+ const limit = [5, 10, 25].includes(limitParam) ? limitParam : 5;
+ const mode: RankingMode =
+ modeParam === 'average' || modeParam === 'median' ? modeParam : 'totals';
+ // Averaging (or taking the median) over very few scenarios lets a single
+ // lucky match dominate the ranking. Default to a higher minimum sample
+ // size in those views; Totals can stay at 1+ since it isn't skewed the
+ // same way. An explicit ?lbMin= always wins over this default.
+ const minimumScenarios = [1, 3, 10, 25].includes(minimumScenariosParam)
+ ? minimumScenariosParam
+ : mode === 'totals'
+ ? 1
+ : 10;
+ const [expandedTeam, setExpandedTeam] = useState();
+ const [selectedPlayer, setSelectedPlayer] = useState<{
+ realm: 'Order' | 'Destruction';
+ standout: Standout;
+ }>();
+ const [shareStatus, setShareStatus] = useState('');
+
+ 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);
+ }
+ }
+ setSearchParams(next, { replace: true });
+ };
+
+ // A range like 'range=24h' is relative to "now", so a copied link would
+ // show a different window whenever it's reopened. Resolve it to an
+ // absolute start/end timestamp at share-time so the link keeps showing
+ // this exact window later.
+ const buildShareUrl = (): string => {
+ const shared = new URLSearchParams(searchParams);
+ if (range !== 'custom' && range !== 'recent') {
+ const now = new Date();
+ now.setSeconds(0, 0);
+ let start: Date | undefined;
+ switch (range) {
+ case '1h': {
+ start = new Date(now.getTime() - 60 * 60 * 1000);
+ break;
+ }
+ case '24h': {
+ start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '7d': {
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '30d': {
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case '90d': {
+ start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ case 'ytd': {
+ start = new Date(now.getFullYear(), 0, 1);
+ break;
+ }
+ }
+ if (start) {
+ shared.set('range', 'custom');
+ shared.set('from', start.toISOString());
+ shared.set('to', now.toISOString());
+ }
+ }
+ return `${window.location.origin}${window.location.pathname}?${shared.toString()}`;
+ };
+
+ useEffect(() => {
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setExpandedTeam(undefined);
+ setSelectedPlayer(undefined);
+ }
+ };
+ document.addEventListener('keydown', closeOnEscape);
+ return () => {
+ document.removeEventListener('keydown', closeOnEscape);
+ };
+ }, []);
+
+ const careerOptions = Object.values(Career).filter(
+ (careerOption) =>
+ role === 'all' || scenarioCareerRoles[careerOption] === role,
+ );
+ const queueTypeLabels: Record = {
+ all: 'All types',
+ standard: 'Standard',
+ solo: 'Random Scenarios',
+ city_siege: 'City Siege',
+ group_challenge: 'Group Challenge',
+ };
+ const tierLabels: Record = {
+ all: 'All tiers',
+ '1': 'Tier 1',
+ '3': 'Tier 2–3',
+ '4': 'Tier 4',
+ };
+ const rangeLabels: Record = {
+ recent: 'Most recent',
+ '1h': 'Last hour',
+ '24h': 'Last 24 hours',
+ '7d': 'Last 7 days',
+ '30d': 'Last 30 days',
+ '90d': 'Last 90 days',
+ ytd: 'Year to date',
+ custom: 'Custom dates',
+ };
+
+ return (
+ <>
+
+
+ Type
+
+ {
+ updateParams({
+ queue_type:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All types
+ Standard
+ Random Scenario
+ City Siege
+ Group Challenge
+
+
+
+
+ Tier
+
+ {
+ updateParams({
+ tier:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All tiers
+ Tier 1
+ Tier 2–3
+ Tier 4
+
+
+
+
+ Time
+
+ {
+ const nextRange = event.target.value;
+ if (nextRange === 'custom') {
+ const today = new Date();
+ const sevenDaysAgo = new Date(
+ today.getTime() - 7 * 24 * 60 * 60 * 1000,
+ );
+ updateParams({
+ from:
+ searchParams.get('from') ?? dateInputValue(sevenDaysAgo),
+ range: 'custom',
+ to: searchParams.get('to') ?? dateInputValue(today),
+ });
+ } else {
+ updateParams({
+ from: undefined,
+ range: nextRange === defaultRange ? undefined : nextRange,
+ to: undefined,
+ });
+ }
+ }}
+ >
+ {defaultRange === 'recent' && (
+ Most recent
+ )}
+ Last hour
+ Last 24 hours
+ Last 7 days
+ Last 30 days
+ Last 90 days
+ Year to date
+ Custom dates
+
+
+
+ {range === 'custom' && (
+ <>
+
+ Start
+ {
+ updateParams({ from: event.target.value || undefined });
+ }}
+ />
+
+
+ End
+ {
+ updateParams({ to: event.target.value || undefined });
+ }}
+ />
+
+ >
+ )}
+
+ Realm
+
+ {
+ updateParams({
+ lbRealm:
+ event.target.value === 'both'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ Both realms
+ Order
+ Destruction
+
+
+
+
+ Role
+
+ {
+ updateParams({
+ lbCareer: undefined,
+ lbRole:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All roles
+ {scenarioRoleOrder.map((roleOption) => (
+
+ {roleOption}
+
+ ))}
+
+
+
+
+ Career
+
+ {
+ updateParams({
+ lbCareer:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All careers
+ {careerOptions.map((careerOption) => (
+
+ {scenarioCareerName(careerOption)}
+
+ ))}
+
+
+
+
+ Rank by
+
+ {
+ updateParams({
+ lbMetric:
+ event.target.value === 'winRate'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ {Object.entries(metricLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ Results
+
+ {
+ updateParams({
+ lbLimit:
+ event.target.value === '5' ? undefined : event.target.value,
+ });
+ }}
+ >
+ Top 5
+ Top 10
+ Top 25
+
+
+
+
+ Minimum scenarios
+
+ {
+ updateParams({ lbMin: event.target.value });
+ }}
+ >
+ 1+
+ 3+
+ 10+
+ 25+
+
+
+
+
+ Share view
+ {
+ void navigator.clipboard
+ .writeText(buildShareUrl())
+ .then(() => {
+ setShareStatus('Link copied');
+ window.setTimeout(() => {
+ setShareStatus('');
+ }, 1800);
+ })
+ .catch(() => {
+ setShareStatus('Copy failed');
+ });
+ }}
+ >
+
+
+
+ {shareStatus || 'Copy link'}
+
+
+
+
+
+ {rangeLabels[range] ?? 'Most recent'} ·{' '}
+ {tierLabels[tier] ?? 'All tiers'} ·{' '}
+ {queueTypeLabels[queueType] ?? 'All types'} ·{' '}
+ {scenarios.length.toLocaleString()} matches
+
+ {
+ const next = new URLSearchParams(searchParams);
+ [
+ 'queue_type',
+ 'tier',
+ 'range',
+ 'from',
+ 'to',
+ 'lbRealm',
+ 'lbRole',
+ 'lbCareer',
+ 'lbMetric',
+ 'lbLimit',
+ 'lbMin',
+ 'lbMode',
+ ].forEach((key) => {
+ next.delete(key);
+ });
+ setSearchParams(next, { replace: true });
+ }}
+ >
+
+
+
+ Reset filters
+
+
+
+ How rankings work: totals cover the scenarios currently
+ loaded on this page. Use "Rank by" to sort by a specific stat
+ (win rate, kills, kill damage, damage, healing, protection, or objective
+ score) — there is no single combined score, since combat, healing,
+ and objective play aren't directly comparable. Average and Median
+ default to a 10+ scenario minimum so a single lucky match can't top
+ the board; lower it manually if you want to see small samples. Open a
+ player for averages and full-profile access. Longer time windows remain
+ responsive by loading matches in batches; use Show more scenarios to
+ expand the analysis.
+
+
+ {realm !== 'destruction' && (
+ {
+ setExpandedTeam(0);
+ }}
+ onModeChange={(nextMode) => {
+ updateParams({
+ lbMode: nextMode === 'totals' ? undefined : nextMode,
+ });
+ }}
+ onSelectPlayer={(standout, selectedRealm) => {
+ setSelectedPlayer({ realm: selectedRealm, standout });
+ }}
+ />
+ )}
+ {realm !== 'order' && (
+ {
+ setExpandedTeam(1);
+ }}
+ onModeChange={(nextMode) => {
+ updateParams({
+ lbMode: nextMode === 'totals' ? undefined : nextMode,
+ });
+ }}
+ onSelectPlayer={(standout, selectedRealm) => {
+ setSelectedPlayer({ realm: selectedRealm, standout });
+ }}
+ />
+ )}
+
+
+ {expandedTeam !== undefined && (
+
+
{
+ setExpandedTeam(undefined);
+ }}
+ />
+
+
{
+ setExpandedTeam(undefined);
+ }}
+ />
+
+
+
+
+ Role
+
+ {
+ updateParams({
+ lbCareer: undefined,
+ lbRole:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All roles
+ {scenarioRoleOrder.map((roleOption) => (
+
+ {roleOption}
+
+ ))}
+
+
+
+
+ Career
+
+ {
+ updateParams({
+ lbCareer:
+ event.target.value === 'all'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ All careers
+ {careerOptions.map((careerOption) => (
+
+ {scenarioCareerName(careerOption)}
+
+ ))}
+
+
+
+
+ Rank by
+
+ {
+ updateParams({
+ lbMetric:
+ event.target.value === 'winRate'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ {Object.entries(metricLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ Minimum scenarios
+
+ {
+ updateParams({ lbMin: event.target.value });
+ }}
+ >
+ 1+
+ 3+
+ 10+
+ 25+
+
+
+
+
+ Numbers shown
+
+ {
+ updateParams({
+ lbMode:
+ event.target.value === 'totals'
+ ? undefined
+ : event.target.value,
+ });
+ }}
+ >
+ Totals
+ Per scenario
+ Median scenario
+
+
+
+
+ {
+ setSelectedPlayer({ realm: selectedRealm, standout });
+ }}
+ />
+
+
+
+ )}
+ {selectedPlayer && (
+
+
{
+ setSelectedPlayer(undefined);
+ }}
+ />
+
+
{
+ setSelectedPlayer(undefined);
+ }}
+ />
+
+
+
+
+
+ {selectedPlayer.standout.name}
+
+
+ {selectedPlayer.realm} ·{' '}
+ {scenarioCareerName(selectedPlayer.standout.career)} ·
+ current activity window
+
+
+
+
+
+
+
+ {selectedPlayer.standout.scenarios}
+ Scenarios
+
+
+
+ {Math.round(selectedPlayer.standout.winRate * 100)}%
+
+ Win rate
+
+
+ {selectedPlayer.standout.kills}
+ Kills
+
+
+ {selectedPlayer.standout.deathBlows}
+ Death blows
+
+
+
+ {compactNumber(selectedPlayer.standout.damage)}
+
+ Total damage
+
+
+
+ {compactNumber(selectedPlayer.standout.healing)}
+
+ Total healing
+
+
+
+ {compactNumber(selectedPlayer.standout.protection)}
+
+ Total protection
+
+
+
+ {compactNumber(selectedPlayer.standout.objectiveScore)}
+
+ Objective score
+
+
+
+ Per scenario:{' '}
+ {compactNumber(
+ selectedPlayer.standout.damage /
+ selectedPlayer.standout.scenarios,
+ )}{' '}
+ damage ·{' '}
+ {compactNumber(
+ selectedPlayer.standout.healing /
+ selectedPlayer.standout.scenarios,
+ )}{' '}
+ healing ·{' '}
+ {compactNumber(
+ selectedPlayer.standout.protection /
+ selectedPlayer.standout.scenarios,
+ )}{' '}
+ protection
+
+
+ Open full character profile
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/src/components/scenario/scenarioRoles.ts b/src/components/scenario/scenarioRoles.ts
new file mode 100644
index 00000000..878856b7
--- /dev/null
+++ b/src/components/scenario/scenarioRoles.ts
@@ -0,0 +1,44 @@
+import { Career } from '@/__generated__/graphql';
+
+export type ScenarioRole = 'Tank' | 'Melee DPS' | 'Ranged DPS' | 'Healer';
+
+export const scenarioRoleOrder: ScenarioRole[] = [
+ 'Tank',
+ 'Melee DPS',
+ 'Ranged DPS',
+ 'Healer',
+];
+
+export const scenarioCareerRoles: Record = {
+ [Career.Archmage]: 'Healer',
+ [Career.BlackGuard]: 'Tank',
+ [Career.BlackOrc]: 'Tank',
+ [Career.BrightWizard]: 'Ranged DPS',
+ [Career.Choppa]: 'Melee DPS',
+ [Career.Chosen]: 'Tank',
+ [Career.DiscipleOfKhaine]: 'Healer',
+ [Career.Engineer]: 'Ranged DPS',
+ [Career.IronBreaker]: 'Tank',
+ [Career.KnightOfTheBlazingSun]: 'Tank',
+ [Career.Magus]: 'Ranged DPS',
+ [Career.Marauder]: 'Melee DPS',
+ [Career.RunePriest]: 'Healer',
+ [Career.ShadowWarrior]: 'Ranged DPS',
+ [Career.Shaman]: 'Healer',
+ [Career.Slayer]: 'Melee DPS',
+ [Career.Sorcerer]: 'Ranged DPS',
+ [Career.SquigHerder]: 'Ranged DPS',
+ [Career.SwordMaster]: 'Tank',
+ [Career.WarriorPriest]: 'Healer',
+ [Career.WhiteLion]: 'Melee DPS',
+ [Career.WitchElf]: 'Melee DPS',
+ [Career.WitchHunter]: 'Melee DPS',
+ [Career.Zealot]: 'Healer',
+};
+
+export const scenarioCareerName = (career: Career): string =>
+ career
+ .toLowerCase()
+ .split('_')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
diff --git a/src/components/skirmish/SkirmishFilters.tsx b/src/components/skirmish/SkirmishFilters.tsx
index 750660aa..f061e120 100644
--- a/src/components/skirmish/SkirmishFilters.tsx
+++ b/src/components/skirmish/SkirmishFilters.tsx
@@ -69,58 +69,44 @@ export const SkirmishFilters = (): ReactElement => {
const locationType = search.get('type') || 'all';
return (
-
-
-
-
-
-
- {t('skirmishFilters.locationType')}
-
- {
- search.set('type', event.target.value);
- setSearch(search);
- }}
- >
-
- {t('skirmishFilters.locationTypeAll')}
-
-
- {t('skirmishFilters.locationTypeRvr')}
-
-
- {t('skirmishFilters.locationTypeRvrT1')}
-
-
- {t('skirmishFilters.locationTypeScenario')}
-
-
-
-
-
-
- {t('skirmishFilters.minPlayers')}
- {
- search.set('minPlayers', event.target.value);
- setSearch(search);
- }}
- >
-
- {t('skirmishFilters.minPlayersAll')}
-
- {t('skirmishFilters.minPlayers12')}
- {t('skirmishFilters.minPlayers48')}
-
- {t('skirmishFilters.minPlayers100')}
-
-
-
-
+
+
+ {t('skirmishFilters.locationType')}
+
+ {
+ search.set('type', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('skirmishFilters.locationTypeAll')}
+ {t('skirmishFilters.locationTypeRvr')}
+
+ {t('skirmishFilters.locationTypeRvrT1')}
+
+
+ {t('skirmishFilters.locationTypeScenario')}
+
+
-
+
+
+ {t('skirmishFilters.minPlayers')}
+
+ {
+ search.set('minPlayers', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('skirmishFilters.minPlayersAll')}
+ {t('skirmishFilters.minPlayers12')}
+ {t('skirmishFilters.minPlayers48')}
+ {t('skirmishFilters.minPlayers100')}
+
+
+
);
};
diff --git a/src/components/skirmish/SkirmishList.tsx b/src/components/skirmish/SkirmishList.tsx
index 33fa7bfd..d965c314 100644
--- a/src/components/skirmish/SkirmishList.tsx
+++ b/src/components/skirmish/SkirmishList.tsx
@@ -13,7 +13,7 @@ export const SkirmishList = ({
query,
queryOptions,
perPage,
- title = undefined,
+ title,
showZone = true,
}: {
query: DocumentNode;
diff --git a/src/i18n/en/components.json b/src/i18n/en/components.json
index b33262a9..d569c7c4 100644
--- a/src/i18n/en/components.json
+++ b/src/i18n/en/components.json
@@ -32,7 +32,8 @@
"price": "Price",
"zone": "Zone",
"destruction": "Destruction",
- "order": "Order"
+ "order": "Order",
+ "filterItems": "Filter items"
},
"itemQuests": {
"questName": "Quest Name",
@@ -123,6 +124,18 @@
"destruction": "Destruction",
"surrender": "Surrender",
"tier": "Tier",
+ "type": "Type",
+ "players": "Players",
+ "recentScenarios": "recent scenarios",
+ "playerSpots": "player spots",
+ "averagePlayers": "average players",
+ "averageDuration": "average duration",
+ "deaths": "deaths",
+ "showPlayers": "Players",
+ "hidePlayers": "Hide",
+ "recentActivity": "Recent scenario activity",
+ "refresh": "Refresh",
+ "showMore": "Show more scenarios",
"scenarioDuration": "{{minutes}}m {{seconds}}s",
"scenarioDurationHour": "{{hours}}h {{minutes}}m {{seconds}}s"
},
@@ -131,9 +144,11 @@
"queueTypeAll": "All",
"queueTypeStandard": "Standard",
"queueTypeSolo": "Pickup",
+ "queueTypeDiscordant": "Random Scenarios",
"queueTypeCitySiege": "City Siege",
"queueTypeGroupRanked": "Group Ranked",
"queueTypeSoloRanked": "Solo Ranked",
+ "queueTypeGroupChallenge": "Group Challenge",
"premadeOnly": "Premade Only",
"tier": "Tier",
"tierAll": "All"
diff --git a/src/i18n/en/pages.json b/src/i18n/en/pages.json
index 28a075ce..c38803cb 100644
--- a/src/i18n/en/pages.json
+++ b/src/i18n/en/pages.json
@@ -28,7 +28,12 @@
"showGuildLeaderboard": "Guilds",
"showPlayerLeaderboard": "Players",
"showScenarios": "Scenarios",
- "showSkirmishes": "Skirmishes"
+ "showSkirmishes": "Skirmishes",
+ "showItems": "Items",
+ "showQuests": "Quests",
+ "showCreatures": "Creatures",
+ "showInstances": "Instances",
+ "showStorylines": "Storylines"
},
"killPage": {
"killer": "Killer",
@@ -112,9 +117,13 @@
},
"creatures": {
"title": "Creatures",
+ "id": "ID",
"search": "Name",
"name": "Name",
- "creatureSubType": "Type"
+ "creatureSubType": "Type",
+ "realm": "Realm",
+ "role": "Role",
+ "location": "Location"
},
"creature": {
"creatureId": "Creature #{{creatureId}}",
@@ -159,6 +168,7 @@
"questGivers": "Quest Givers"
},
"instances": {
+ "title": "Instances",
"search": "Search",
"name": "Name",
"encounters": "Encounters",
@@ -171,7 +181,9 @@
"encounter": "Encounter",
"medianDuration": "Median Duration",
"medianDeaths": "Median Deaths",
- "averageDeaths": "Average Deaths"
+ "averageDeaths": "Average Deaths",
+ "completedOnly": "Completed only",
+ "namedBosses": "Named Bosses ({{count}})"
},
"instanceRuns": {
"title": "Instance Runs",
@@ -180,15 +192,15 @@
"instance": "Instance",
"encounters": "Encounters",
"deaths": "Deaths",
- "itemRatingMin": "Min Item",
- "itemRatingMax": "Max Item",
- "itemRatingAverage": "Avg Item",
"numTanks": "Tanks",
"numDps": "Dps",
"numHealers": "Healers",
"all": "All",
"averageDuration": "Average Duration",
- "averageDeaths": "Average Deaths"
+ "averageDeaths": "Average Deaths",
+ "averageDurationUnavailable": "Not available (some runs in this instance have invalid duration data)",
+ "minCompletedEncounters": "Min completed encounters",
+ "averageDurationSampleNote": "Based on the {{sampleSize}} most recent runs; {{excluded}} excluded for exceeding 7 hours (likely abandoned/never-closed sessions)."
},
"instanceRun": {
"title": "#{{id}}",
@@ -198,12 +210,10 @@
"encounters": "Encounters",
"encounter": "Encounter",
"deaths": "Deaths",
- "itemRatingMin": "Min Item",
- "itemRatingMax": "Max Item",
- "itemRatingAverage": "Avg Item",
"numTanks": "Tanks",
"numDps": "Dps",
- "numHealers": "Healers"
+ "numHealers": "Healers",
+ "wipedAttempts": "{{count}} wiped attempt(s)"
},
"instanceEncounterRun": {
"title": "Encounter #{{id}}",
@@ -243,5 +253,51 @@
"taskNumber": "#",
"taskName": "Task",
"zone": "Zone"
+ },
+ "instanceHub": {
+ "title": "Instance",
+ "runsTab": "Runs",
+ "charactersTab": "Characters",
+ "leaderboardsTab": "Leaderboards",
+ "recentRunsNote": "Based on the most recent {{count}} runs.",
+ "totalRuns": "Runs loaded",
+ "completed": "Completed",
+ "notCompleted": "Wiped",
+ "character": "Character",
+ "career": "Career",
+ "runsCount": "Runs",
+ "lastRun": "Last Run",
+ "totalDamage": "Total Damage",
+ "totalHealing": "Total Healing",
+ "totalProtection": "Total Protection",
+ "totalDeaths": "Total Deaths",
+ "role": "Role",
+ "realm": "Realm",
+ "metric": "Metric",
+ "any": "Any",
+ "tank": "Tank",
+ "healer": "Healer",
+ "dps": "DPS",
+ "damage": "Damage",
+ "healing": "Healing",
+ "protection": "Protection",
+ "rank": "Rank",
+ "level": "Level",
+ "bestRun": "Best Run",
+ "time": "Time",
+ "rangeRecent": "Most recent 50",
+ "range24h": "Last 24 hours",
+ "range7d": "Last 7 days",
+ "range30d": "Last 30 days",
+ "range90d": "Last 90 days",
+ "rangeYtd": "Year to date",
+ "rangeCustom": "Custom dates",
+ "startDate": "Start",
+ "endDate": "End",
+ "gatheringRuns": "gathering {{loaded}} of {{total}}",
+ "rangeRunsNote": "{{count}} runs \u00b7 {{rangeLabel}}",
+ "durationOutliersExcluded": "{{count}} run(s) excluded from the average for an implausible duration (likely abandoned/never-closed sessions).",
+ "wipedAttempts": "{{count}} wiped attempt(s)",
+ "runs": "Runs"
}
}
diff --git a/src/index.tsx b/src/index.tsx
index 3417fe13..1ae61a8d 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -18,7 +18,7 @@ const root = createRoot(container);
root.render(
-
+
diff --git a/src/pages/Character.tsx b/src/pages/Character.tsx
index 93e07ecb..38cf1c7f 100644
--- a/src/pages/Character.tsx
+++ b/src/pages/Character.tsx
@@ -5,7 +5,6 @@ import { CharacterRecentDeaths } from '@/components/character/CharacterRecentDea
import { CharacterRecentKills } from '@/components/character/CharacterRecentKills';
import { KillsFilters } from '@/components/kill/KillsFilters';
import { ScenarioList } from '@/components/scenario/ScenarioList';
-import { ScenarioFilters } from '@/components/scenario/ScenarioFilters';
import { CharacterArmory } from '@/components/character/CharacterArmory';
import { ScenarioCount } from '@/components/scenario/ScenarioCount';
import { CharacterLatestSkirmishes } from '@/components/character/CharacterLatestSkirmishes';
@@ -71,7 +70,6 @@ export const Character = ({
)}
{tab === 'scenarios' && (
-
diff --git a/src/pages/Creatures.tsx b/src/pages/Creatures.tsx
index 9d6dfb70..34259f92 100644
--- a/src/pages/Creatures.tsx
+++ b/src/pages/Creatures.tsx
@@ -3,12 +3,17 @@ import { useTranslation } from 'react-i18next';
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import useWindowDimensions from '@/hooks/useWindowDimensions';
-import type { CreatureFilterInput, Query } from '@/__generated__/graphql';
+import {
+ Realm,
+ type CreatureFilterInput,
+ type Query,
+} from '@/__generated__/graphql';
import { ErrorMessage } from '@/components/global/ErrorMessage';
import { SearchBox } from '@/components/global/SearchBox';
import { QueryPagination } from '@/components/global/QueryPagination';
import type { ReactElement } from 'react';
import clsx from 'clsx';
+import { creatureTitleIcon, creatureTitleLabel } from '../utils';
const CREATURES = gql`
query GetCreatures(
@@ -29,6 +34,13 @@ const CREATURES = gql`
id
name
realm
+ title
+ spawns {
+ zone {
+ id
+ name
+ }
+ }
}
pageInfo {
hasNextPage
@@ -69,18 +81,13 @@ export const Creatures = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return
;
- }
- if (error) {
- return
;
- }
- if (data?.creatures?.nodes == null) {
- return
;
- }
-
- const entries = data.creatures.nodes;
- const { pageInfo } = data.creatures;
+ // The search box lives outside the loading/error branches below so it
+ // never unmounts while typing -- live filtering refetches on every
+ // keystroke pause, and re-rendering the whole page (search box
+ // included) around a fresh
element used to yank focus out
+ // of the input mid-word.
+ const entries = data?.creatures?.nodes;
+ const { pageInfo } = data?.creatures ?? {};
return (
@@ -95,51 +102,131 @@ export const Creatures = (): ReactElement => {
-
-
-
- {t('pages:creatures.search')}
- {
- search.set('name', event);
- setSearch(search);
- }}
- />
-
-
+
+
+ {t('pages:creatures.search')}
+ {
+ search.set('name', event);
+ setSearch(search);
+ }}
+ />
+
-
-
-
-
- {t('pages:creatures.name')}
-
-
-
- {entries.map((creature) => (
-
-
- {creature.name}
-
+ {loading && entries == null && }
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+
+
+
+
+ {t('pages:creatures.id')}
+ {t('pages:creatures.name')}
+ {t('pages:creatures.realm')}
+ {t('pages:creatures.role')}
+ {t('pages:creatures.location')}
- ))}
-
-
-
-
+
+
+ {entries.map((creature) => {
+ const zoneNames = [
+ ...new Set(
+ creature.spawns
+ .map((spawn) => spawn.zone?.name)
+ .filter((name): name is string => Boolean(name)),
+ ),
+ ];
+ const icon = creatureTitleIcon(creature.title);
+ const label = creatureTitleLabel(creature.title);
+
+ return (
+
+ {creature.id}
+
+
+ {creature.name}
+
+
+
+ {creature.realm === Realm.Order && (
+
+
+
+
+ {t('common:realmOrder')}
+
+ )}
+ {creature.realm === Realm.Destruction && (
+
+
+
+
+ {t('common:realmDestruction')}
+
+ )}
+ {creature.realm == null && (
+ {t('common:realmNeutral')}
+ )}
+
+
+ {label && (
+
+ {icon && (
+
+
+
+ )}
+ {label}
+
+ )}
+
+
+ {zoneNames.length > 0 && (
+
+ {zoneNames[0]}
+ {zoneNames.length > 1 &&
+ ` (+${zoneNames.length - 1})`}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ )}
+ {entries != null && pageInfo && (
+
+ )}
);
};
diff --git a/src/pages/Guild.tsx b/src/pages/Guild.tsx
index 329551f6..e63d7e9c 100644
--- a/src/pages/Guild.tsx
+++ b/src/pages/Guild.tsx
@@ -9,7 +9,6 @@ import { GuildMemberList } from '@/components/guild/GuildMemberList';
import { GUILD_INFO_FRAGMENT, GuildInfo } from '@/components/guild/GuildInfo';
import { KillsFilters } from '@/components/kill/KillsFilters';
import { ScenarioList } from '@/components/scenario/ScenarioList';
-import { ScenarioFilters } from '@/components/scenario/ScenarioFilters';
import { ScenarioCount } from '@/components/scenario/ScenarioCount';
import { GuildLatestSkirmishes } from '@/components/guild/GuildLatestSkirmishes';
import type { ReactElement } from 'react';
@@ -127,7 +126,6 @@ export const Guild = ({
{tab === 'members' &&
}
{tab === 'scenarios' && (
-
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 88974bbf..68ada408 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,13 +1,9 @@
-import clsx from 'clsx';
-import { useTranslation } from 'react-i18next';
-import { Link } from 'react-router';
import { LatestKills } from '@/components/kill/LatestKills';
import { WeeklyLeaderboard } from '@/components/kill/WeeklyLeaderboard';
import { SearchBox } from '@/components/global/SearchBox';
import { MonthlyLeaderboard } from '@/components/kill/MonthlyLeaderboard';
import { MonthlyGuildLeaderboard } from '@/components/kill/MonthlyLeaderboard.Guild';
import { WeeklyLeaderboardGuild } from '@/components/kill/WeeklyLeaderboardGuild';
-import { ScenarioFilters } from '@/components/scenario/ScenarioFilters';
import { ScenarioList } from '@/components/scenario/ScenarioList';
import { LatestSkirmishes } from '@/components/skirmish/LatestSkirmishes';
import { TopSkirmishes } from '@/components/skirmish/TopSkirmishes';
@@ -18,33 +14,12 @@ export const Home = ({
}: {
tab: 'players' | 'guilds' | 'scenarios' | 'skirmishes';
}): ReactElement => {
- const { t } = useTranslation();
-
return (
-
-
-
- {t('pages:home.showPlayerLeaderboard')}
-
-
- {t('pages:home.showGuildLeaderboard')}
-
-
- {t('pages:home.showScenarios')}
-
-
- {t('pages:home.showSkirmishes')}
-
-
- {tab === 'scenarios' && (
- <>
-
-
- >
- )}
+
+ {tab === 'scenarios' &&
}
{tab === 'players' && (
<>
-
+
@@ -58,7 +33,7 @@ export const Home = ({
)}
{tab === 'guilds' && (
<>
-
+
diff --git a/src/pages/InstanceEncounterRun.tsx b/src/pages/InstanceEncounterRun.tsx
index cf6e1f1a..60f59263 100644
--- a/src/pages/InstanceEncounterRun.tsx
+++ b/src/pages/InstanceEncounterRun.tsx
@@ -102,10 +102,7 @@ export const InstanceEncounterRun = (): ReactElement => {
- {t('common:home')}
-
-
- {t('common:instanceRuns')}
+ {t('common:instances')}
diff --git a/src/pages/InstanceHub.tsx b/src/pages/InstanceHub.tsx
new file mode 100644
index 00000000..8d95bc89
--- /dev/null
+++ b/src/pages/InstanceHub.tsx
@@ -0,0 +1,1222 @@
+import { gql } from '@apollo/client';
+import { useApolloClient, useQuery } from '@apollo/client/react';
+import { useTranslation } from 'react-i18next';
+import { Link, useParams, useSearchParams } from 'react-router';
+import { Fragment, useEffect, useMemo, useState } from 'react';
+import { format, formatDuration, intervalToDuration } from 'date-fns';
+import type { ReactElement } from 'react';
+import clsx from 'clsx';
+import {
+ Archetype,
+ Career,
+ type InstanceRunFilterInput,
+ type InstanceRunScoreboardEntryFragment,
+ type Query,
+} from '@/__generated__/graphql';
+import { ErrorMessage } from '@/components/global/ErrorMessage';
+import { CareerIcon } from '@/components/CareerIcon';
+import useWindowDimensions from '@/hooks/useWindowDimensions';
+import { SortConfigDirection, useSortableData } from '@/hooks/useSortableData';
+import { parseFilterDate } from '@/components/scenario/ScenarioFilters';
+import {
+ getInstanceGroupById,
+ getInstanceGroupByIdOrFallback,
+} from '@/utils/instanceGroups';
+import { INSTANCE_RUN_SCOREBOARD_FRAGMENT } from '@/components/instance_run/InstanceRunScoreboard';
+
+// Inspired by maartenson.net's per-dungeon Runs/Characters/Leaderboards
+// tabs. That site tracks its own historical data with hourly graphs and a
+// date-compare tool, which would need its own scraper and years of
+// storage to replicate. Simpler approach here: the API already supports
+// filtering instanceRuns by instanceId AND by start date range, and
+// returns each run's full scoreboard inline, so this page can page
+// through the live API for whatever window is selected instead of
+// standing up a new Worker/D1 cache. The default "Most recent 50" tab
+// stays a single fast request; anything else (24h/7d/30d/90d/YTD/custom)
+// walks the API in batches, the same trick already used by the Scenarios
+// page's time-window loader.
+const RUNS_TO_LOAD = 50;
+const WINDOW_BATCH_SIZE = 50;
+// Some instanceRuns rows never get a proper end timestamp written
+// (abandoned/never-closed sessions); anything longer than this is treated as
+// bad data rather than a real dungeon clear when averaging durations.
+const MAX_PLAUSIBLE_DURATION_MS = 7 * 60 * 60 * 1000; // 7 hours
+
+const INSTANCE_HUB_META = gql`
+ query InstanceHubMeta($id: ID!) {
+ instance(id: $id) {
+ id
+ name
+ }
+ }
+`;
+
+const INSTANCE_HUB_RUNS = gql`
+ query InstanceHubRuns(
+ $where: InstanceRunFilterInput!
+ $first: Int
+ $after: String
+ ) {
+ instanceRuns(
+ where: $where
+ first: $first
+ after: $after
+ order: { start: DESC }
+ ) {
+ totalCount
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+ nodes {
+ id
+ start
+ end
+ completed
+ encounters {
+ encounterId
+ completed
+ }
+ scoreboardEntries {
+ ...InstanceRunScoreboardEntry
+ }
+ }
+ }
+ }
+ ${INSTANCE_RUN_SCOREBOARD_FRAGMENT}
+`;
+
+// Every WAR career has an exact mirror in the other realm. There's no
+// realm field on a scoreboard entry itself (only the character's guild
+// has one, and not everyone is guilded), so this is a static lookup.
+const ORDER_CAREERS = new Set([
+ Career.Archmage,
+ Career.BrightWizard,
+ Career.Engineer,
+ Career.IronBreaker,
+ Career.KnightOfTheBlazingSun,
+ Career.RunePriest,
+ Career.ShadowWarrior,
+ Career.Slayer,
+ Career.SwordMaster,
+ Career.WarriorPriest,
+ Career.WhiteLion,
+ Career.WitchHunter,
+]);
+
+type RangeKey = '24h' | '30d' | '7d' | '90d' | 'custom' | 'recent' | 'ytd';
+type RealmFilter = 'all' | 'ORDER' | 'DESTRUCTION';
+type RoleFilter = 'all' | Archetype;
+type MetricKey = 'damage' | 'healing' | 'protection' | 'runs';
+
+const careerRealm = (career: Career): 'ORDER' | 'DESTRUCTION' =>
+ ORDER_CAREERS.has(career) ? 'ORDER' : 'DESTRUCTION';
+
+interface HubRun {
+ completed: boolean;
+ encounters: { completed: boolean; encounterId: string }[];
+ end: string;
+ id: string;
+ scoreboardEntries: InstanceRunScoreboardEntryFragment[];
+ start: string;
+}
+
+// The InstanceRun's own `completed` field tracks whether the session was
+// properly closed server-side, not whether the group actually downed
+// everything they fought - the vast majority of runs are `completed: false`
+// even when every boss they pulled died. Derive a "cleared" label instead
+// from the encounters themselves: true only if every distinct boss the group
+// engaged was eventually downed (no encounter left as a standing wipe).
+const runWasCleared = (run: HubRun): boolean => {
+ if (run.encounters.length === 0) {
+ return false;
+ }
+ const downedByEncounterId = new Map();
+ for (const encounter of run.encounters) {
+ downedByEncounterId.set(
+ encounter.encounterId,
+ (downedByEncounterId.get(encounter.encounterId) ?? false) ||
+ encounter.completed,
+ );
+ }
+ return [...downedByEncounterId.values()].every(Boolean);
+};
+
+interface RunCluster {
+ primary: HubRun;
+ wiped: HubRun[];
+}
+
+// Separate InstanceRun rows recorded close together in time are almost
+// always the same group's session: a few short wiped attempts followed by
+// the run that actually cleared everything. Flat-listing all of them makes
+// the Runs tab look like it's mostly failures. Cluster runs whose gap to the
+// previous run's end is under 20 minutes, surface the cluster's cleared run
+// (or its last attempt, if none cleared) as the visible row, and nest the
+// rest as collapsible detail.
+const CLUSTER_GAP_MS = 20 * 60 * 1000;
+
+const clusterRuns = (runs: HubRun[]): RunCluster[] => {
+ const chronological = runs.toSorted(
+ (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
+ );
+
+ const groups: HubRun[][] = [];
+ for (const run of chronological) {
+ const currentGroup = groups[groups.length - 1];
+ const previousRun = currentGroup?.[currentGroup.length - 1];
+ const gapMs = previousRun
+ ? new Date(run.start).getTime() - new Date(previousRun.end).getTime()
+ : undefined;
+
+ if (currentGroup && gapMs !== undefined && gapMs <= CLUSTER_GAP_MS) {
+ currentGroup.push(run);
+ } else {
+ groups.push([run]);
+ }
+ }
+
+ return groups
+ .map((group) => {
+ const cleared = group.filter(runWasCleared);
+ const primary = cleared.length > 0 ? cleared.at(-1)! : group.at(-1)!;
+ return {
+ latestStart: group.at(-1)!.start,
+ primary,
+ wiped: group.filter((run) => run.id !== primary.id),
+ };
+ })
+ .toSorted(
+ (a, b) =>
+ new Date(b.latestStart).getTime() - new Date(a.latestStart).getTime(),
+ )
+ .map(({ primary, wiped }) => ({ primary, wiped }));
+};
+
+interface CharacterSummary {
+ career: Career;
+ id: string;
+ lastRunStart: string;
+ name: string;
+ runCount: number;
+ totalDamage: number;
+ totalDeaths: number;
+ totalHealing: number;
+ totalProtection: number;
+}
+
+interface LeaderboardEntry {
+ career: Career;
+ characterId: string;
+ level: number;
+ name: string;
+ renownRank: number;
+ runCount: number;
+ runId: string;
+ value: number;
+}
+
+const summarizeCharacters = (runs: HubRun[]): CharacterSummary[] => {
+ const byCharacter = new Map();
+
+ for (const run of runs) {
+ for (const entry of run.scoreboardEntries) {
+ const existing = byCharacter.get(entry.character.id);
+ if (existing) {
+ existing.runCount += 1;
+ existing.totalDamage += Number(entry.damage);
+ existing.totalHealing += Number(entry.healing);
+ existing.totalProtection += Number(entry.protection);
+ existing.totalDeaths += Number(entry.deaths);
+ if (run.start > existing.lastRunStart) {
+ existing.lastRunStart = run.start;
+ existing.career = entry.character.career;
+ }
+ } else {
+ byCharacter.set(entry.character.id, {
+ career: entry.character.career,
+ id: entry.character.id,
+ lastRunStart: run.start,
+ name: entry.character.name,
+ runCount: 1,
+ totalDamage: Number(entry.damage),
+ totalDeaths: Number(entry.deaths),
+ totalHealing: Number(entry.healing),
+ totalProtection: Number(entry.protection),
+ });
+ }
+ }
+ }
+
+ return [...byCharacter.values()].toSorted(
+ (a, b) =>
+ b.runCount - a.runCount || (a.lastRunStart < b.lastRunStart ? 1 : -1),
+ );
+};
+
+// "Best performance" leaderboard: one row per character, their single
+// highest value for the metric across loaded runs (not a sum) - matches
+// how maartenson's Leaderboards tab reads.
+const buildLeaderboard = ({
+ runs,
+ metric,
+ role,
+ realm,
+}: {
+ runs: HubRun[];
+ metric: MetricKey;
+ role: RoleFilter;
+ realm: RealmFilter;
+}): LeaderboardEntry[] => {
+ const best = new Map();
+ const runCounts = new Map();
+
+ for (const run of runs) {
+ for (const entry of run.scoreboardEntries) {
+ const matchesRole = role === 'all' || entry.archetype === role;
+ const matchesRealm =
+ realm === 'all' || careerRealm(entry.character.career) === realm;
+ if (matchesRole && matchesRealm) {
+ runCounts.set(
+ entry.character.id,
+ (runCounts.get(entry.character.id) ?? 0) + 1,
+ );
+
+ // Runs isn't a per-scoreboard-entry stat, so there's no "best single
+ // run" to compare - just keep the character's most recent matching
+ // run as their representative "Best Run" link, and rank by total
+ // run count afterwards instead.
+ const value = metric === 'runs' ? 0 : Number(entry[metric]);
+ const current = best.get(entry.character.id);
+ if (!current || metric === 'runs' || value > current.value) {
+ best.set(entry.character.id, {
+ career: entry.character.career,
+ characterId: entry.character.id,
+ level: Number(entry.level),
+ name: entry.character.name,
+ renownRank: Number(entry.renownRank),
+ runCount: 0,
+ runId: run.id,
+ value,
+ });
+ }
+ }
+ }
+ }
+
+ return [...best.values()]
+ .map((entry) => {
+ const runCount = runCounts.get(entry.characterId) ?? 0;
+ return {
+ ...entry,
+ runCount,
+ value: metric === 'runs' ? runCount : entry.value,
+ };
+ })
+ .toSorted((a, b) => b.value - a.value);
+};
+
+const formatDurationBetween = (start: string, end: string): string =>
+ formatDuration(
+ intervalToDuration({ end: new Date(end), start: new Date(start) }),
+ );
+
+// Undefined start/end means "recent" mode (no date filter at all - handled
+// by the fast single-request path instead of the windowed loader).
+const getHubTimeWindow = (
+ search: URLSearchParams,
+): { end?: Date; start?: Date } | undefined => {
+ const range = (search.get('range') as RangeKey | null) ?? 'recent';
+ const now = new Date();
+ now.setMinutes(Math.floor(now.getMinutes() / 5) * 5, 0, 0);
+
+ switch (range) {
+ case '24h': {
+ return { end: now, start: new Date(now.getTime() - 24 * 60 * 60 * 1000) };
+ }
+ case '7d': {
+ return {
+ end: now,
+ start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
+ };
+ }
+ case '30d': {
+ return {
+ end: now,
+ start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
+ };
+ }
+ case '90d': {
+ return {
+ end: now,
+ start: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000),
+ };
+ }
+ case 'ytd': {
+ return { end: now, start: new Date(now.getFullYear(), 0, 1) };
+ }
+ case 'custom': {
+ const from = search.get('from');
+ const to = search.get('to');
+ return {
+ end: to ? parseFilterDate(to, true) : now,
+ start: from ? parseFilterDate(from, false) : undefined,
+ };
+ }
+ default: {
+ return undefined;
+ }
+ }
+};
+
+const buildRunsWhere = (
+ instanceIds: number[],
+ window?: { end?: Date; start?: Date },
+): InstanceRunFilterInput => ({
+ instanceId:
+ instanceIds.length === 1
+ ? { eq: instanceIds[0] }
+ : { in: instanceIds },
+ ...(window?.start || window?.end
+ ? {
+ start: {
+ ...(window.start && !Number.isNaN(window.start.getTime())
+ ? { gte: window.start.toISOString() }
+ : {}),
+ ...(window.end && !Number.isNaN(window.end.getTime())
+ ? { lte: window.end.toISOString() }
+ : {}),
+ },
+ }
+ : {}),
+});
+
+const RANGE_LABEL_KEYS: Record = {
+ '24h': 'pages:instanceHub.range24h',
+ '7d': 'pages:instanceHub.range7d',
+ '30d': 'pages:instanceHub.range30d',
+ '90d': 'pages:instanceHub.range90d',
+ custom: 'pages:instanceHub.rangeCustom',
+ recent: 'pages:instanceHub.rangeRecent',
+ ytd: 'pages:instanceHub.rangeYtd',
+};
+
+export const InstanceHub = ({
+ tab,
+}: {
+ tab: 'characters' | 'leaderboards' | 'runs';
+}): ReactElement => {
+ const { id } = useParams();
+ const [search, setSearch] = useSearchParams();
+ const { t } = useTranslation(['common', 'pages', 'enums']);
+ const { width } = useWindowDimensions();
+ const isMobile = width <= 768;
+ const client = useApolloClient();
+
+ const knownGroup = id ? getInstanceGroupById(Number(id)) : undefined;
+ // A handful of instance IDs are really just one wing of a larger dungeon
+ // (see src/utils/instanceGroups.ts) - for those, the curated group name is
+ // used directly and this query is skipped entirely. It only runs for
+ // instance IDs the curated list doesn't recognize, purely to get a display
+ // name for the fallback single-instance "group".
+ const { data: metaData } = useQuery(INSTANCE_HUB_META, {
+ skip: !id || knownGroup != null,
+ variables: { id },
+ });
+ const group = id
+ ? getInstanceGroupByIdOrFallback(Number(id), metaData?.instance?.name)
+ : undefined;
+ const instanceName = group?.name;
+
+ const range = (search.get('range') as RangeKey | null) ?? 'recent';
+ const isWindowed = range !== 'recent';
+ const timeWindow = getHubTimeWindow(search);
+ const windowKey = `${range}|${search.get('from') ?? ''}|${search.get('to') ?? ''}`;
+
+ const {
+ data: recentData,
+ loading: recentLoading,
+ error: recentError,
+ } = useQuery(INSTANCE_HUB_RUNS, {
+ skip: !id || isWindowed || !group,
+ variables: {
+ first: RUNS_TO_LOAD,
+ where: group ? buildRunsWhere(group.instanceIds) : {},
+ },
+ });
+
+ const [windowRuns, setWindowRuns] = useState([]);
+ const [windowTotal, setWindowTotal] = useState(0);
+ const [windowLoading, setWindowLoading] = useState(false);
+ const [windowError, setWindowError] = useState();
+
+ useEffect(() => {
+ if (!isWindowed || !id || !group) {
+ setWindowRuns([]);
+ setWindowTotal(0);
+ setWindowLoading(false);
+ setWindowError(undefined);
+ return;
+ }
+
+ let cancelled = false;
+ let total: number | undefined;
+ const controller = new AbortController();
+ const where = buildRunsWhere(group.instanceIds, timeWindow);
+
+ // Cursors from this API are base64-encoded zero-based offsets, so an
+ // arbitrary [start, start + count) window can be requested directly.
+ const encodeOffset = (offset: number): string | undefined =>
+ offset > 0 ? btoa(String(offset - 1)) : undefined;
+
+ // A single malformed row nulls out the whole array it's in under
+ // GraphQL's non-null propagation rules. Rather than losing an entire
+ // batch when that happens, split the failing range in half and retry
+ // each half until the bad row(s) are isolated.
+ const fetchRange = async (
+ start: number,
+ count: number,
+ ): Promise<{ nodes: HubRun[]; total: number }> => {
+ if (count <= 0) {
+ return { nodes: [], total: 0 };
+ }
+ const result = await client.query({
+ context: { fetchOptions: { signal: controller.signal } },
+ errorPolicy: 'all',
+ fetchPolicy: 'cache-first',
+ query: INSTANCE_HUB_RUNS,
+ variables: { after: encodeOffset(start), first: count, where },
+ });
+ const connection = result.data?.instanceRuns;
+ if (!connection) {
+ return { nodes: [], total: 0 };
+ }
+ if (connection.nodes) {
+ return {
+ nodes: connection.nodes as HubRun[],
+ total: connection.totalCount,
+ };
+ }
+ if (count === 1) {
+ return { nodes: [], total: connection.totalCount };
+ }
+ const half = Math.ceil(count / 2);
+ const left = await fetchRange(start, half);
+ const right = await fetchRange(start + half, count - half);
+ return {
+ nodes: [...left.nodes, ...right.nodes],
+ total: left.total || right.total,
+ };
+ };
+
+ const loadWindow = async (): Promise => {
+ setWindowRuns([]);
+ setWindowTotal(0);
+ setWindowError(undefined);
+ setWindowLoading(true);
+ let offset = 0;
+ const accumulated: HubRun[] = [];
+
+ try {
+ do {
+ const { nodes, total: batchTotal } = await fetchRange(
+ offset,
+ WINDOW_BATCH_SIZE,
+ );
+ accumulated.push(...nodes);
+ offset += WINDOW_BATCH_SIZE;
+ if (total === undefined) {
+ total = batchTotal;
+ if (!cancelled) {
+ setWindowTotal(batchTotal);
+ }
+ }
+ if (!cancelled) {
+ setWindowRuns([...accumulated]);
+ }
+ if (total === undefined || offset >= total) {
+ break;
+ }
+ } while (!cancelled);
+ } catch (caughtError) {
+ if (!cancelled && !controller.signal.aborted) {
+ setWindowError(
+ caughtError instanceof Error
+ ? caughtError
+ : new Error('Unable to load the selected time window.'),
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setWindowLoading(false);
+ }
+ }
+ };
+
+ void loadWindow();
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [client, id, isWindowed, windowKey]);
+
+ const loading = isWindowed
+ ? windowLoading && windowRuns.length === 0
+ : recentLoading;
+ const error = isWindowed ? windowError : recentError;
+ const runs = (
+ isWindowed ? windowRuns : (recentData?.instanceRuns?.nodes ?? [])
+ ) as HubRun[];
+ const totalCount = isWindowed
+ ? windowTotal
+ : (recentData?.instanceRuns?.totalCount ?? 0);
+
+ const runClusters = useMemo(() => clusterRuns(runs), [runs]);
+ const [expandedClusters, setExpandedClusters] = useState>(
+ new Set(),
+ );
+ const toggleCluster = (primaryId: string): void => {
+ setExpandedClusters((previous) => {
+ const next = new Set(previous);
+ if (next.has(primaryId)) {
+ next.delete(primaryId);
+ } else {
+ next.add(primaryId);
+ }
+ return next;
+ });
+ };
+
+ const characters = useMemo(() => summarizeCharacters(runs), [runs]);
+ const {
+ items: sortedCharacters,
+ requestSort: requestCharacterSort,
+ sortConfig: characterSortConfig,
+ } = useSortableData(characters, {
+ direction: SortConfigDirection.descending,
+ key: 'runCount',
+ });
+ const getCharacterSortClass = (key: string): string => {
+ if (!characterSortConfig || characterSortConfig.key !== key) {
+ return '';
+ }
+ return characterSortConfig.direction;
+ };
+
+ const metric = (search.get('metric') as MetricKey | null) ?? 'damage';
+ const role = (search.get('role') as RoleFilter | null) ?? 'all';
+ const realm = (search.get('realm') as RealmFilter | null) ?? 'all';
+ const leaderboard = useMemo(
+ () => buildLeaderboard({ metric, realm, role, runs }),
+ [runs, metric, role, realm],
+ );
+
+ // Computed from the loaded batch (not the connection's all-history
+ // averageDuration/averageDeaths fields) so this stays consistent with
+ // whichever window is currently displayed. Some runs never get a proper
+ // end timestamp written (abandoned/never-closed sessions) and can carry a
+ // multi-day duration that would badly skew a plain average, so anything
+ // over 7 hours - implausible for a real dungeon clear - is excluded and
+ // counted separately.
+ const durationStats = useMemo(() => {
+ if (runs.length === 0) {
+ return null;
+ }
+ const durationsMs = runs
+ .map(
+ (run) => new Date(run.end).getTime() - new Date(run.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 {
+ excluded: durationsMs.length - saneDurationsMs.length,
+ text: formatDuration(
+ intervalToDuration({
+ end: new Date(
+ Math.round(
+ saneDurationsMs.reduce((a, b) => a + b, 0) /
+ saneDurationsMs.length,
+ ),
+ ),
+ start: new Date(0),
+ }),
+ ),
+ };
+ }, [runs]);
+ const averageDurationText = durationStats?.text ?? null;
+
+ const averageDeaths =
+ runs.length === 0
+ ? null
+ : (
+ runs.reduce(
+ (sum, run) =>
+ sum +
+ run.scoreboardEntries.reduce(
+ (deaths, entry) => deaths + Number(entry.deaths),
+ 0,
+ ),
+ 0,
+ ) / runs.length
+ ).toFixed(1);
+
+ const setRange = (nextRange: RangeKey): void => {
+ if (nextRange === 'custom') {
+ const today = new Date();
+ const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
+ search.set('range', 'custom');
+ search.set(
+ 'from',
+ search.get('from') ?? sevenDaysAgo.toISOString().slice(0, 10),
+ );
+ search.set('to', search.get('to') ?? today.toISOString().slice(0, 10));
+ } else {
+ if (nextRange === 'recent') {
+ search.delete('range');
+ } else {
+ search.set('range', nextRange);
+ }
+ search.delete('from');
+ search.delete('to');
+ }
+ setSearch(search);
+ };
+
+ return (
+
+
+
+
+ {t('common:instances')}
+
+
+
+ {instanceName ?? t('pages:instanceHub.title')}
+
+
+
+
+
+
+
+ {t('pages:instanceHub.runsTab')}
+
+
+
+ {t('pages:instanceHub.charactersTab')}
+
+
+
+
+ {t('pages:instanceHub.leaderboardsTab')}
+
+
+
+
+
+
+ {loading &&
}
+ {!loading && error && (
+
+ )}
+ {!loading && !error && runs.length === 0 && (
+
+ )}
+ {!loading && !error && runs.length > 0 && (
+ <>
+
+ {isWindowed
+ ? t('pages:instanceHub.rangeRunsNote', {
+ count: runs.length,
+ rangeLabel: t(RANGE_LABEL_KEYS[range]),
+ })
+ : t('pages:instanceHub.recentRunsNote', { count: runs.length })}
+ {isWindowed && windowLoading && (
+ <>
+ {' · '}
+ {t('pages:instanceHub.gatheringRuns', {
+ loaded: runs.length,
+ total: windowTotal || '…',
+ })}
+ >
+ )}
+
+
+ {tab === 'runs' && (
+ <>
+
+
+
+
+
{t('pages:instanceHub.totalRuns')}
+
{totalCount.toLocaleString()}
+
+
+
{t('pages:instanceRuns.averageDuration')}
+
+ {averageDurationText ??
+ t('pages:instanceRuns.averageDurationUnavailable')}
+
+ {durationStats != null && durationStats.excluded > 0 && (
+
+ {t('pages:instanceHub.durationOutliersExcluded', {
+ count: durationStats.excluded,
+ })}
+
+ )}
+
+
+
{t('pages:instanceRuns.averageDeaths')}
+
{averageDeaths}
+
+
+
+
+
+
+
+
+ {t('pages:instanceRuns.startTime')}
+ {t('pages:instanceRuns.duration')}
+ {t('pages:instanceHub.completed')}
+ {t('pages:instanceRuns.deaths')}
+
+
+
+
+ {runClusters.map(({ primary, wiped }) => {
+ const expanded = expandedClusters.has(primary.id);
+ const cleared = runWasCleared(primary);
+
+ return (
+
+
+
+
+ {format(
+ new Date(primary.start),
+ 'yyyy-MM-dd HH:mm',
+ )}
+
+
+
+ {formatDurationBetween(
+ primary.start,
+ primary.end,
+ )}
+
+
+ {cleared
+ ? t('pages:instanceHub.completed')
+ : t('pages:instanceHub.notCompleted')}
+ {wiped.length > 0 && (
+ <>
+ {' '}
+ toggleCluster(primary.id)}
+ >
+ {t('pages:instanceHub.wipedAttempts', {
+ count: wiped.length,
+ })}{' '}
+ {expanded ? '▾' : '▸'}
+
+ >
+ )}
+
+
+ {primary.scoreboardEntries
+ .map((entry) => Number(entry.deaths))
+ .reduce((a, b) => a + b, 0)}
+
+
+
+ {t('common:details')}
+
+
+
+ {expanded &&
+ wiped.map((run) => (
+
+
+
+ {'↳ '}
+ {format(
+ new Date(run.start),
+ 'yyyy-MM-dd HH:mm',
+ )}
+
+
+
+ {formatDurationBetween(run.start, run.end)}
+
+ {t('pages:instanceHub.notCompleted')}
+
+ {run.scoreboardEntries
+ .map((entry) => Number(entry.deaths))
+ .reduce((a, b) => a + b, 0)}
+
+
+
+ {t('common:details')}
+
+
+
+ ))}
+
+ );
+ })}
+
+
+
+ >
+ )}
+
+ {tab === 'characters' && (
+
+
+
+
+ requestCharacterSort('name')}
+ >
+ {t('pages:instanceHub.character')}
+
+ requestCharacterSort('career')}
+ >
+ {t('pages:instanceHub.career')}
+
+ requestCharacterSort('runCount')}
+ >
+ {t('pages:instanceHub.runsCount')}
+
+ requestCharacterSort('lastRunStart')}
+ >
+ {t('pages:instanceHub.lastRun')}
+
+ requestCharacterSort('totalDamage')}
+ >
+ {t('pages:instanceHub.totalDamage')}
+
+ requestCharacterSort('totalHealing')}
+ >
+ {t('pages:instanceHub.totalHealing')}
+
+ requestCharacterSort('totalProtection')}
+ >
+ {t('pages:instanceHub.totalProtection')}
+
+ requestCharacterSort('totalDeaths')}
+ >
+ {t('pages:instanceHub.totalDeaths')}
+
+
+
+
+ {sortedCharacters.map((character) => (
+
+
+
+ {character.name}
+
+
+
+
+
+ {t(`enums:career.${character.career}`)}
+
+
+ {character.runCount}
+
+
+ {format(
+ new Date(character.lastRunStart),
+ 'yyyy-MM-dd HH:mm',
+ )}
+
+
+
+ {character.totalDamage.toLocaleString()}
+
+
+ {character.totalHealing.toLocaleString()}
+
+
+ {character.totalProtection.toLocaleString()}
+
+ {character.totalDeaths}
+
+ ))}
+
+
+
+ )}
+
+ {tab === 'leaderboards' && (
+ <>
+
+
+ {t('pages:instanceHub.metric')}
+
+ {
+ search.set('metric', event.target.value);
+ setSearch(search);
+ }}
+ >
+
+ {t('pages:instanceHub.damage')}
+
+
+ {t('pages:instanceHub.healing')}
+
+
+ {t('pages:instanceHub.protection')}
+
+ {t('pages:instanceHub.runs')}
+
+
+
+
+ {t('pages:instanceHub.role')}
+
+ {
+ search.set('role', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('pages:instanceHub.any')}
+
+ {t('pages:instanceHub.tank')}
+
+
+ {t('pages:instanceHub.healer')}
+
+
+ {t('pages:instanceHub.dps')}
+
+
+ {t('pages:instanceHub.dps')}
+
+
+
+
+
+ {t('pages:instanceHub.realm')}
+
+ {
+ search.set('realm', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('pages:instanceHub.any')}
+ {t('common:realmOrder')}
+
+ {t('common:realmDestruction')}
+
+
+
+
+
+
+
+
+
+ {t('pages:instanceHub.rank')}
+ {t('pages:instanceHub.character')}
+ {t('pages:instanceHub.career')}
+ {t('pages:instanceHub.level')}
+ {t(`pages:instanceHub.${metric}`)}
+ {metric !== 'runs' && (
+ {t('pages:instanceHub.runs')}
+ )}
+
+
+
+
+ {leaderboard.map((entry, index) => (
+
+ {index + 1}
+
+
+ {entry.name}
+
+
+
+
+
+ {t(`enums:career.${entry.career}`)}
+
+
+
+ CR {entry.level} · RR {entry.renownRank}
+
+ {entry.value.toLocaleString()}
+ {metric !== 'runs' && (
+ {entry.runCount}
+ )}
+
+
+ {t('pages:instanceHub.bestRun')}
+
+
+
+ ))}
+
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/src/pages/InstanceRun.tsx b/src/pages/InstanceRun.tsx
index 011ec33f..bb145b4d 100644
--- a/src/pages/InstanceRun.tsx
+++ b/src/pages/InstanceRun.tsx
@@ -8,6 +8,7 @@ import {
intervalToDuration,
} from 'date-fns';
import { Link, useParams } from 'react-router';
+import { Fragment, useState } from 'react';
import { ErrorMessage } from '@/components/global/ErrorMessage';
import { Archetype } from '@/__generated__/graphql';
import useWindowDimensions from '@/hooks/useWindowDimensions';
@@ -40,7 +41,6 @@ const INSTANCE_RUN = gql`
instanceId
encounterId
scoreboardEntries {
- itemRating
archetype
deaths
damage
@@ -66,6 +66,9 @@ export const InstanceRun = (): ReactElement => {
});
const { width } = useWindowDimensions();
const isMobile = width <= 768;
+ const [expandedEncounters, setExpandedEncounters] = useState>(
+ new Set(),
+ );
if (loading || !data?.instanceRun?.encounters) {
return ;
@@ -84,13 +87,6 @@ export const InstanceRun = (): ReactElement => {
});
const instanceDuration = formatDuration(instanceDurationObject);
- const instanceItemRatings = instanceRun.scoreboardEntries.map(
- (e) => e.itemRating,
- );
- const instanceItemRatingMin = Math.min(...instanceItemRatings);
- const instanceItemRatingMax = Math.max(...instanceItemRatings);
- const instanceItemRatingAverage =
- instanceItemRatings.reduce((a, b) => a + b) / instanceItemRatings.length;
const instanceNumTanks = instanceRun.scoreboardEntries.filter(
(e) => e.archetype === Archetype.Tank,
).length;
@@ -101,15 +97,41 @@ export const InstanceRun = (): ReactElement => {
[Archetype.MeleeDps, Archetype.RangedDps].includes(e.archetype),
).length;
+ // A boss that took a few pulls to down shows up as several encounter
+ // attempts sharing the same encounterId. Group them: the attempt that
+ // finally killed it (or the last attempt, if it never went down) is the
+ // visible row, and the earlier wiped pulls on that same boss nest under it
+ // as collapsible detail instead of flat-listing every pull as its own row.
+ const encounterAttemptsByEncounterId = new Map<
+ string,
+ typeof instanceRun.encounters
+ >();
+ for (const attempt of instanceRun.encounters) {
+ const attempts =
+ encounterAttemptsByEncounterId.get(attempt.encounterId) ?? [];
+ attempts.push(attempt);
+ encounterAttemptsByEncounterId.set(attempt.encounterId, attempts);
+ }
+ const encounterGroups = [...encounterAttemptsByEncounterId.values()].map(
+ (attempts) => {
+ const downedAttempts = attempts.filter((attempt) => attempt.completed);
+ const primary =
+ downedAttempts.length > 0
+ ? downedAttempts.at(-1)!
+ : attempts.at(-1)!;
+ return {
+ primary,
+ wiped: attempts.filter((attempt) => attempt.id !== primary.id),
+ };
+ },
+ );
+
return (
- {t('common:home')}
-
-
- {t('common:instanceRuns')}
+ {t('common:instances')}
@@ -136,20 +158,6 @@ export const InstanceRun = (): ReactElement => {
{instanceDuration}
-
-
- {t('pages:instanceRun.itemRatingMin')} {' '}
- {instanceItemRatingMin}
-
-
- {t('pages:instanceRun.itemRatingAverage')} {' '}
- {instanceItemRatingAverage.toFixed(0)}
-
-
- {t('pages:instanceRun.itemRatingMax')} {' '}
- {instanceItemRatingMax}
-
-
{t('pages:instanceRun.numTanks')} {' '}
@@ -183,20 +191,7 @@ export const InstanceRun = (): ReactElement => {
{t('pages:instanceRun.startTime')}
{t('pages:instanceRun.encounter')}
{t('pages:instanceRun.duration')}
-
-
-
-
-
-
{t('pages:instanceRun.itemRatingMin')}
-
{t('pages:instanceRun.itemRatingAverage')}
-
{t('pages:instanceRun.itemRatingMax')}
+
{t('pages:instanceRun.deaths')}
{
- {data.instanceRun.encounters.map((instanceEncounterRun) => {
- const startDate = new Date(instanceEncounterRun.start);
- const endDate = new Date(instanceEncounterRun.end);
- const durationObject = intervalToDuration({
- end: endDate,
- start: startDate,
- });
-
- const duration = formatDuration(durationObject);
- const itemRatings = instanceEncounterRun.scoreboardEntries.map(
- (e) => e.itemRating,
+ {encounterGroups.map(({ primary, wiped }) => {
+ const startDate = new Date(primary.start);
+ const endDate = new Date(primary.end);
+ const duration = formatDuration(
+ intervalToDuration({ end: endDate, start: startDate }),
);
- const itemRatingMin = Math.min(...itemRatings);
- const itemRatingMax = Math.max(...itemRatings);
- const itemRatingAverage =
- itemRatings.reduce((a, b) => a + b) / itemRatings.length;
- const numTanks = instanceEncounterRun.scoreboardEntries.filter(
+ const numTanks = primary.scoreboardEntries.filter(
(e) => e.archetype === Archetype.Tank,
).length;
- const numHealers = instanceEncounterRun.scoreboardEntries.filter(
+ const numHealers = primary.scoreboardEntries.filter(
(e) => e.archetype === Archetype.Healer,
).length;
- const numDPS = instanceEncounterRun.scoreboardEntries.filter((e) =>
+ const numDPS = primary.scoreboardEntries.filter((e) =>
[Archetype.MeleeDps, Archetype.RangedDps].includes(e.archetype),
).length;
+ const expanded = expandedEncounters.has(primary.id);
return (
-
-
-
- {formatISO(startDate, { representation: 'date' })}
-
- {format(startDate, 'HH:mm')}
-
-
-
- {' '}
- {instanceEncounterRun.completed ? (
-
-
-
+
+
+
+
+ {formatISO(startDate, { representation: 'date' })}
+
+ {format(startDate, 'HH:mm')}
+
+
+
+ {' '}
+ {primary.completed ? (
+
+
+
+
+ {primary.encounter?.name}
- {instanceEncounterRun.encounter?.name}
-
- ) : (
-
-
-
+ ) : (
+
+
+
+
+ {primary.encounter?.name}
- {instanceEncounterRun.encounter?.name}
-
- )}
-
-
- {duration}
-
-
- {instanceEncounterRun.scoreboardEntries
- .map((e) => e.deaths)
- .reduce((a, b) => a + b, 0)}
-
- {itemRatingMin}
- {itemRatingAverage.toFixed(0)}
- {itemRatingMax}
- {numTanks}
- {numHealers}
- {numDPS}
-
-
- {t('common:details')}
-
-
-
+ )}
+ {wiped.length > 0 && (
+ <>
+ {' '}
+
+ setExpandedEncounters((current) => {
+ const next = new Set(current);
+ if (next.has(primary.id)) {
+ next.delete(primary.id);
+ } else {
+ next.add(primary.id);
+ }
+ return next;
+ })
+ }
+ >
+ {t('pages:instanceRun.wipedAttempts', {
+ count: wiped.length,
+ })}{' '}
+ {expanded ? '▾' : '▸'}
+
+ >
+ )}
+
+
+ {duration}
+
+
+ {primary.scoreboardEntries
+ .map((e) => e.deaths)
+ .reduce((a, b) => a + b, 0)}
+
+ {numTanks}
+ {numHealers}
+ {numDPS}
+
+
+ {t('common:details')}
+
+
+
+ {expanded &&
+ wiped.map((attempt) => {
+ const attemptStart = new Date(attempt.start);
+ const attemptEnd = new Date(attempt.end);
+ const attemptDuration = formatDuration(
+ intervalToDuration({
+ end: attemptEnd,
+ start: attemptStart,
+ }),
+ );
+ const attemptNumTanks = attempt.scoreboardEntries.filter(
+ (e) => e.archetype === Archetype.Tank,
+ ).length;
+ const attemptNumHealers =
+ attempt.scoreboardEntries.filter(
+ (e) => e.archetype === Archetype.Healer,
+ ).length;
+ const attemptNumDPS = attempt.scoreboardEntries.filter(
+ (e) =>
+ [Archetype.MeleeDps, Archetype.RangedDps].includes(
+ e.archetype,
+ ),
+ ).length;
+
+ return (
+
+
+
+ {'↳ '}
+ {formatISO(attemptStart, {
+ representation: 'date',
+ })}{' '}
+ {format(attemptStart, 'HH:mm')}
+
+
+
+
+
+
+
+ {attempt.encounter?.name}
+
+
+
+ {attemptDuration}
+
+
+ {attempt.scoreboardEntries
+ .map((e) => e.deaths)
+ .reduce((a, b) => a + b, 0)}
+
+ {attemptNumTanks}
+ {attemptNumHealers}
+ {attemptNumDPS}
+
+
+ {t('common:details')}
+
+
+
+ );
+ })}
+
);
})}
diff --git a/src/pages/InstanceRuns.tsx b/src/pages/InstanceRuns.tsx
index 4ff8461e..79f9031c 100644
--- a/src/pages/InstanceRuns.tsx
+++ b/src/pages/InstanceRuns.tsx
@@ -12,7 +12,7 @@ export const InstanceRuns = (): ReactElement => {
- {t('common:home')}
+ {t('common:instances')}
{t('pages:instanceRuns.title')}
diff --git a/src/pages/InstanceStatistics.tsx b/src/pages/InstanceStatistics.tsx
index c810e10a..aa6919cb 100644
--- a/src/pages/InstanceStatistics.tsx
+++ b/src/pages/InstanceStatistics.tsx
@@ -2,6 +2,7 @@ import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router';
+import { useMemo, useState } from 'react';
import { ErrorMessage } from '@/components/global/ErrorMessage';
import type { Query } from '@/__generated__/graphql';
import useWindowDimensions from '@/hooks/useWindowDimensions';
@@ -9,47 +10,84 @@ import clsx from 'clsx';
import { InstanceEncounterRunsFilters } from '@/components/instance_statistics/InstanceEncounterRunsFilters';
import { InstanceEncounterStatistics } from '@/components/instance_statistics/InstanceEncounterStatistics';
import type { ReactElement } from 'react';
+import { getInstanceGroupByIdOrFallback } from '@/utils/instanceGroups';
const INSTANCE_STATISTICS = gql`
- query InstanceEncounters($id: ID!) {
- instance(id: $id) {
- id
- name
- encounters {
+ query InstanceEncounters($ids: [ID!]) {
+ instances(where: { id: { in: $ids } }) {
+ nodes {
id
name
+ encounters {
+ id
+ name
+ }
}
}
}
`;
+interface EncounterRow {
+ encounterId: number;
+ instanceId: number;
+ name: string;
+}
+
export const InstanceStatistics = (): ReactElement => {
const { id } = useParams();
const { t } = useTranslation(['common', 'pages']);
+ // A handful of instance IDs are really just one wing of a larger dungeon
+ // (see src/utils/instanceGroups.ts) - query every underlying instance ID
+ // in the group so this page shows one combined encounter list for the
+ // whole dungeon rather than just whichever wing the URL happens to name.
+ const group = id ? getInstanceGroupByIdOrFallback(Number(id)) : undefined;
const { data, error, loading } = useQuery(INSTANCE_STATISTICS, {
+ skip: !group,
variables: {
- id,
+ ids: group?.instanceIds ?? [],
},
});
+
+ const rows = useMemo(
+ () =>
+ (data?.instances?.nodes ?? []).flatMap((instance) =>
+ (instance.encounters ?? [])
+ .filter(
+ (encounter): encounter is { id: string; name: string } =>
+ encounter != null,
+ )
+ .map((encounter) => ({
+ encounterId: Number(encounter.id),
+ instanceId: Number(instance.id),
+ name: encounter.name,
+ })),
+ ),
+ [data],
+ );
+
+ // Bastion Stair's four named bosses (and similar cases like Gunbad's Squig
+ // Boss) are each their own instance ID under the hood - keep the group's
+ // main dungeon encounters as the primary list, and tuck the named-boss
+ // instance IDs into a collapsible section beneath it instead of mixing
+ // them into one flat list.
+ const coreRows = rows.filter((row) => row.instanceId === group?.id);
+ const namedBossRows = rows.filter((row) => row.instanceId !== group?.id);
+ const [showNamedBosses, setShowNamedBosses] = useState(false);
+
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading || !data?.instance?.encounters) {
+ if (loading || !data?.instances) {
return ;
}
if (error) {
return ;
}
- const { instance } = data;
-
return (
-
- {t('common:home')}
-
{t('common:instances')}
@@ -62,7 +100,7 @@ export const InstanceStatistics = (): ReactElement => {
- {instance.name}
+ {group?.name}
@@ -107,14 +145,41 @@ export const InstanceStatistics = (): ReactElement => {
- {data.instance.encounters.map((instanceEncounter) => (
+ {coreRows.map((row) => (
))}
+ {namedBossRows.length > 0 && (
+ <>
+
+
+ setShowNamedBosses((current) => !current)}
+ >
+ {t('pages:instanceStatistics.namedBosses', {
+ count: namedBossRows.length,
+ })}{' '}
+ {showNamedBosses ? '▾' : '▸'}
+
+
+
+ {showNamedBosses &&
+ namedBossRows.map((row) => (
+
+ ))}
+ >
+ )}
diff --git a/src/pages/Instances.tsx b/src/pages/Instances.tsx
index 590e0271..9c41b5da 100644
--- a/src/pages/Instances.tsx
+++ b/src/pages/Instances.tsx
@@ -2,32 +2,21 @@ import { Link, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
-import useWindowDimensions from '@/hooks/useWindowDimensions';
-import type {
- GetInstancesQuery,
- InstanceFilterInput,
-} from '@/__generated__/graphql';
+import { useMemo } from 'react';
+import type { GetInstancesQuery } from '@/__generated__/graphql';
import { ErrorMessage } from '@/components/global/ErrorMessage';
import { SearchBox } from '@/components/global/SearchBox';
-import { QueryPagination } from '@/components/global/QueryPagination';
import type { ReactElement } from 'react';
-import clsx from 'clsx';
+import { INSTANCE_GROUPS } from '@/utils/instanceGroups';
+
+// There are only ~20 instances total (a handful of which get merged into
+// single dungeon groups below), so the whole list fits in one request - no
+// need for the pagination the site uses on genuinely large lists.
+const MAX_INSTANCES = 50;
const QUERY = gql`
- query GetInstances(
- $first: Int
- $last: Int
- $before: String
- $after: String
- $where: InstanceFilterInput
- ) {
- instances(
- first: $first
- last: $last
- before: $before
- after: $after
- where: $where
- ) {
+ query GetInstances($first: Int) {
+ instances(first: $first) {
nodes {
id
name
@@ -35,133 +24,126 @@ const QUERY = gql`
id
}
}
- pageInfo {
- hasNextPage
- endCursor
- hasPreviousPage
- startCursor
- }
}
}
`;
-const getInstanceNameFilter = (
- search: URLSearchParams,
-): InstanceFilterInput => {
- const name = search.get('name');
-
- if (!name) {
- return {};
- }
-
- return { name: { contains: name } };
-};
-
-const getFilters = (search: URLSearchParams): InstanceFilterInput => ({
- ...getInstanceNameFilter(search),
-});
+interface InstanceCard {
+ encounterCount: number;
+ id: number;
+ name: string;
+}
export const Instances = (): ReactElement => {
- const perPage = 15;
const [search, setSearch] = useSearchParams();
- const { t } = useTranslation(['common', 'pages', 'enums']);
- const { loading, error, data, refetch } = useQuery