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/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..6a5b439d 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}}",
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/Instances.tsx b/src/pages/Instances.tsx
index 590e0271..ff2fa4fb 100644
--- a/src/pages/Instances.tsx
+++ b/src/pages/Instances.tsx
@@ -74,18 +74,8 @@ export const Instances = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return
;
- }
- if (error) {
- return
;
- }
- if (data?.instances?.nodes == null) {
- return
;
- }
-
- const entries = data.instances.nodes;
- const { pageInfo } = data.instances;
+ const entries = data?.instances?.nodes;
+ const { pageInfo } = data?.instances ?? {};
return (
@@ -95,73 +85,82 @@ export const Instances = (): ReactElement => {
{t('common:home')}
- {t('pages:creatures.title')}
+ {t('pages:instances.title')}
-
-
-
- {t('pages:instances.search')}
- {
- search.set('name', event);
- setSearch(search);
- }}
- />
-
-
+
+
+ {t('pages:instances.search')}
+ {
+ search.set('name', event);
+ setSearch(search);
+ }}
+ />
+
-
-
-
-
- {t('pages:instances.name')}
- {t('pages:instances.encounters')}
-
-
-
-
- {entries.map((instance) => (
-
-
- {instance.name}
-
- {instance.encounters?.length || 0}
-
-
- {t('pages:instances.statistics')}
-
-
- {t('pages:instances.runs')}
-
-
+ {loading && entries == null && }
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+
+
+
+
+ {t('pages:instances.name')}
+ {t('pages:instances.encounters')}
+
- ))}
-
-
-
-
+
+
+ {entries.map((instance) => (
+
+
+ {instance.name}
+
+ {instance.encounters?.length || 0}
+
+
+ {t('pages:instances.statistics')}
+
+
+ {t('pages:instances.runs')}
+
+
+
+ ))}
+
+
+
+ )}
+ {entries != null && pageInfo && (
+
+ )}
);
};
diff --git a/src/pages/Item.tsx b/src/pages/Item.tsx
index e852ce47..30490b0d 100644
--- a/src/pages/Item.tsx
+++ b/src/pages/Item.tsx
@@ -121,7 +121,7 @@ export const Item = ({
const activeTab = activeTabs.includes(tab) ? tab : activeTabs[0];
return (
-
+
diff --git a/src/pages/Items.tsx b/src/pages/Items.tsx
index 2918b2e4..17996a36 100644
--- a/src/pages/Items.tsx
+++ b/src/pages/Items.tsx
@@ -113,21 +113,11 @@ export const Items = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return ;
- }
- if (error) {
- return ;
- }
- if (data?.items?.nodes == null) {
- return ;
- }
-
- const entries = data.items.nodes;
- const { pageInfo } = data.items;
+ const entries = data?.items?.nodes;
+ const { pageInfo } = data?.items ?? {};
return (
-
+
@@ -138,331 +128,305 @@ export const Items = (): ReactElement => {
-
-
-
-
{t('pages:items.search')}
-
{
- search.set('query', event);
+
+
+ {t('pages:items.search')}
+ {
+ search.set('query', event);
+ setSearch(search);
+ }}
+ />
+
+
+ {t('pages:items.itemType')}
+
+ {
+ search.set('type', event.target.value);
setSearch(search);
}}
- />
+ >
+ {t('pages:items.all')}
+ {t('enums:itemType.SWORD')}
+ {t('enums:itemType.AXE')}
+ {t('enums:itemType.HAMMER')}
+
+ {t('enums:itemType.BASIC_SHIELD')}
+
+ {t('enums:itemType.SHIELD')}
+ {t('enums:itemType.ROBE')}
+ {t('enums:itemType.BOW')}
+ {t('enums:itemType.CROSSBOW')}
+ {t('enums:itemType.GUN')}
+
+ {t('enums:itemType.EXPERT_SHIELD')}
+
+ {t('enums:itemType.STAFF')}
+ {t('enums:itemType.DAGGER')}
+ {t('enums:itemType.SPEAR')}
+ {t('enums:itemType.PISTOL')}
+ {t('enums:itemType.LANCE')}
+
+ {t('enums:itemType.REPEATING_CROSSBOW')}
+
+
+ {t('enums:itemType.LIGHT_ARMOR')}
+
+
+ {t('enums:itemType.MEDIUM_ARMOR')}
+
+
+ {t('enums:itemType.HEAVY_ARMOR')}
+
+ {t('enums:itemType.QUEST')}
+
+ {t('enums:itemType.MEDIUM_ROBE')}
+
+
+ {t('enums:itemType.ENHANCEMENT')}
+
+ {t('enums:itemType.TROPHY')}
+ {t('enums:itemType.CHARM')}
+ {t('enums:itemType.DYE')}
+
+ {t('enums:itemType.BASIC_MOUNT')}
+
+
+ {t('enums:itemType.ADVANCED_MOUNT')}
+
+ {t('enums:itemType.POTION')}
+ {t('enums:itemType.SALVAGING')}
+ {t('enums:itemType.MARKETING')}
+ {t('enums:itemType.CRAFTING')}
+ {t('enums:itemType.ACCESSORY')}
+ {t('enums:itemType.CURRENCY')}
+ {t('enums:itemType.TELEPORT')}
+
+ {t('enums:itemType.TELEPORT_GROUP')}
+
+ {t('enums:itemType.SIEGE')}
+
+ {t('enums:itemType.TREASURE_CHEST')}
+
+
+ {t('enums:itemType.TREASURE_KEY')}
+
+
+ {t('enums:itemType.REFINER_TOOL')}
+
+
+
+
+
+ {t('pages:items.stat')}
+
+ {
+ search.set('stat', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('pages:items.any')}
+ {t('enums:stat.WOUNDS')}
+
+ {t('enums:stat.WEAPON_SKILL')}
+
+ {t('enums:stat.WILLPOWER')}
+
+ {t('enums:stat.BALLISTIC_SKILL')}
+
+ {t('enums:stat.TOUGHNESS')}
+ {t('enums:stat.INITIATIVE')}
+ {t('enums:stat.STRENGTH')}
+
+ {t('enums:stat.INTELLIGENCE')}
+
+ {t('enums:stat.BLOCK')}
+ {t('enums:stat.PARRY')}
+ {t('enums:stat.DISRUPT')}
+ {t('enums:stat.EVADE')}
+
+ {t('enums:stat.ELEMENTAL_RESISTANCE')}
+
+
+ {t('enums:stat.SPIRIT_RESISTANCE')}
+
+
+ {t('enums:stat.CORPOREAL_RESISTANCE')}
+
+
+ {t('enums:stat.HEAL_CRIT_RATE')}
+
+
+ {t('enums:stat.RANGED_CRIT_RATE')}
+
+
+ {t('enums:stat.MELEE_CRIT_RATE')}
+
+
+ {t('enums:stat.MAGIC_CRIT_RATE')}
+
+
+ {t('enums:stat.ACTION_POINT_REGEN')}
+
+
+ {t('enums:stat.MORALE_REGEN')}
+
+
+ {t('enums:stat.HEALTH_REGEN')}
+
+
+ {t('enums:stat.CRITICAL_HIT_RATE_REDUCTION')}
+
+
+ {t('enums:stat.BLOCK_STRIKETHROUGH')}
+
+
+ {t('enums:stat.PARRY_STRIKETHROUGH')}
+
+
+ {t('enums:stat.DISRUPT_STRIKETHROUGH')}
+
+
+ {t('enums:stat.EVADE_STRIKETHROUGH')}
+
+
+ {t('enums:stat.HEALING_POWER')}
+
+ {t('enums:stat.MAGIC_POWER')}
+
+ {t('enums:stat.RANGED_POWER')}
+
+ {t('enums:stat.MELEE_POWER')}
+ {t('enums:stat.FORTITUDE')}
+
+ {t('enums:stat.AUTO_ATTACK_SPEED')}
+
+
+ {t('enums:stat.AUTO_ATTACK_DAMAGE')}
+
+
+ {t('enums:stat.ARMOR_PENETRATION')}
+
+
+ {t('enums:stat.ARMOR_PENETRATION_REDUCTION')}
+
+ {t('enums:stat.HATE_CAUSED')}
+
+ {t('enums:stat.HATE_RECEIVED')}
+
+
-
-
-
-
- {t('pages:items.itemType')}
-
- {
- search.set('type', event.target.value);
- setSearch(search);
- }}
- >
- {t('pages:items.all')}
- {t('enums:itemType.SWORD')}
- {t('enums:itemType.AXE')}
- {t('enums:itemType.HAMMER')}
-
- {t('enums:itemType.BASIC_SHIELD')}
-
- {t('enums:itemType.SHIELD')}
- {t('enums:itemType.ROBE')}
- {t('enums:itemType.BOW')}
-
- {t('enums:itemType.CROSSBOW')}
-
- {t('enums:itemType.GUN')}
-
- {t('enums:itemType.EXPERT_SHIELD')}
-
- {t('enums:itemType.STAFF')}
- {t('enums:itemType.DAGGER')}
- {t('enums:itemType.SPEAR')}
- {t('enums:itemType.PISTOL')}
- {t('enums:itemType.LANCE')}
-
- {t('enums:itemType.REPEATING_CROSSBOW')}
-
-
- {t('enums:itemType.LIGHT_ARMOR')}
-
-
- {t('enums:itemType.MEDIUM_ARMOR')}
-
-
- {t('enums:itemType.HEAVY_ARMOR')}
-
- {t('enums:itemType.QUEST')}
-
- {t('enums:itemType.MEDIUM_ROBE')}
-
-
- {t('enums:itemType.ENHANCEMENT')}
-
- {t('enums:itemType.TROPHY')}
- {t('enums:itemType.CHARM')}
- {t('enums:itemType.DYE')}
-
- {t('enums:itemType.BASIC_MOUNT')}
-
-
- {t('enums:itemType.ADVANCED_MOUNT')}
-
- {t('enums:itemType.POTION')}
-
- {t('enums:itemType.SALVAGING')}
-
-
- {t('enums:itemType.MARKETING')}
-
-
- {t('enums:itemType.CRAFTING')}
-
-
- {t('enums:itemType.ACCESSORY')}
-
-
- {t('enums:itemType.CURRENCY')}
-
-
- {t('enums:itemType.TELEPORT')}
-
-
- {t('enums:itemType.TELEPORT_GROUP')}
-
- {t('enums:itemType.SIEGE')}
-
- {t('enums:itemType.TREASURE_CHEST')}
-
-
- {t('enums:itemType.TREASURE_KEY')}
-
-
- {t('enums:itemType.REFINER_TOOL')}
-
-
-
-
-
-
-
- {t('pages:items.stat')}
-
- {
- search.set('stat', event.target.value);
- setSearch(search);
- }}
- >
- {t('pages:items.any')}
- {t('enums:stat.WOUNDS')}
-
- {t('enums:stat.WEAPON_SKILL')}
-
- {t('enums:stat.WILLPOWER')}
-
- {t('enums:stat.BALLISTIC_SKILL')}
-
- {t('enums:stat.TOUGHNESS')}
-
- {t('enums:stat.INITIATIVE')}
-
- {t('enums:stat.STRENGTH')}
-
- {t('enums:stat.INTELLIGENCE')}
-
- {t('enums:stat.BLOCK')}
- {t('enums:stat.PARRY')}
- {t('enums:stat.DISRUPT')}
- {t('enums:stat.EVADE')}
-
- {t('enums:stat.ELEMENTAL_RESISTANCE')}
-
-
- {t('enums:stat.SPIRIT_RESISTANCE')}
-
-
- {t('enums:stat.CORPOREAL_RESISTANCE')}
-
-
- {t('enums:stat.HEAL_CRIT_RATE')}
-
-
- {t('enums:stat.RANGED_CRIT_RATE')}
-
-
- {t('enums:stat.MELEE_CRIT_RATE')}
-
-
- {t('enums:stat.MAGIC_CRIT_RATE')}
-
-
- {t('enums:stat.ACTION_POINT_REGEN')}
-
-
- {t('enums:stat.MORALE_REGEN')}
-
-
- {t('enums:stat.HEALTH_REGEN')}
-
-
- {t('enums:stat.CRITICAL_HIT_RATE_REDUCTION')}
-
-
- {t('enums:stat.BLOCK_STRIKETHROUGH')}
-
-
- {t('enums:stat.PARRY_STRIKETHROUGH')}
-
-
- {t('enums:stat.DISRUPT_STRIKETHROUGH')}
-
-
- {t('enums:stat.EVADE_STRIKETHROUGH')}
-
-
- {t('enums:stat.HEALING_POWER')}
-
-
- {t('enums:stat.MAGIC_POWER')}
-
-
- {t('enums:stat.RANGED_POWER')}
-
-
- {t('enums:stat.MELEE_POWER')}
-
- {t('enums:stat.FORTITUDE')}
-
- {t('enums:stat.AUTO_ATTACK_SPEED')}
-
-
- {t('enums:stat.AUTO_ATTACK_DAMAGE')}
-
-
- {t('enums:stat.ARMOR_PENETRATION')}
-
-
- {t('enums:stat.ARMOR_PENETRATION_REDUCTION')}
-
-
- {t('enums:stat.HATE_CAUSED')}
-
-
- {t('enums:stat.HATE_RECEIVED')}
-
-
-
-
-
-
-
- {t('pages:items.usableByCareer')}
-
- {
- search.set('career', event.target.value);
- setSearch(search);
- }}
- >
- {t('pages:items.all')}
- {t('enums:career.ARCHMAGE')}
-
- {t('enums:career.BLACKGUARD')}
-
-
- {t('enums:career.BLACK_ORC')}
-
-
- {t('enums:career.BRIGHT_WIZARD')}
-
- {t('enums:career.CHOPPA')}
- {t('enums:career.CHOSEN')}
-
- {t('enums:career.DISCIPLE_OF_KHAINE')}
-
- {t('enums:career.ENGINEER')}
-
- {t('enums:career.IRON_BREAKER')}
-
-
- {t('enums:career.KNIGHT_OF_THE_BLAZING_SUN')}
-
- {t('enums:career.MAGUS')}
- {t('enums:career.MARAUDER')}
-
- {t('enums:career.RUNE_PRIEST')}
-
-
- {t('enums:career.SHADOW_WARRIOR')}
-
- {t('enums:career.SHAMAN')}
- {t('enums:career.SLAYER')}
- {t('enums:career.SORCERER')}
-
- {t('enums:career.SQUIG_HERDER')}
-
-
- {t('enums:career.SWORD_MASTER')}
-
-
- {t('enums:career.WARRIOR_PRIEST')}
-
-
- {t('enums:career.WHITE_LION')}
-
-
- {t('enums:career.WITCH_ELF')}
-
-
- {t('enums:career.WITCH_HUNTER')}
-
- {t('enums:career.ZEALOT')}
-
-
-
+
+
+ {t('pages:items.usableByCareer')}
+
+ {
+ search.set('career', event.target.value);
+ setSearch(search);
+ }}
+ >
+ {t('pages:items.all')}
+ {t('enums:career.ARCHMAGE')}
+ {t('enums:career.BLACKGUARD')}
+ {t('enums:career.BLACK_ORC')}
+
+ {t('enums:career.BRIGHT_WIZARD')}
+
+ {t('enums:career.CHOPPA')}
+ {t('enums:career.CHOSEN')}
+
+ {t('enums:career.DISCIPLE_OF_KHAINE')}
+
+ {t('enums:career.ENGINEER')}
+
+ {t('enums:career.IRON_BREAKER')}
+
+
+ {t('enums:career.KNIGHT_OF_THE_BLAZING_SUN')}
+
+ {t('enums:career.MAGUS')}
+ {t('enums:career.MARAUDER')}
+
+ {t('enums:career.RUNE_PRIEST')}
+
+
+ {t('enums:career.SHADOW_WARRIOR')}
+
+ {t('enums:career.SHAMAN')}
+ {t('enums:career.SLAYER')}
+ {t('enums:career.SORCERER')}
+
+ {t('enums:career.SQUIG_HERDER')}
+
+
+ {t('enums:career.SWORD_MASTER')}
+
+
+ {t('enums:career.WARRIOR_PRIEST')}
+
+ {t('enums:career.WHITE_LION')}
+ {t('enums:career.WITCH_ELF')}
+
+ {t('enums:career.WITCH_HUNTER')}
+
+ {t('enums:career.ZEALOT')}
+
-
+
-
-
}
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+ <>
+
+
+
+
+
+ {t('pages:items.name')}
+ {t('pages:items.itemType')}
+ {t('pages:items.slot')}
+
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+
+ {pageInfo && (
+
)}
- >
-
-
-
- {t('pages:items.name')}
- {t('pages:items.itemType')}
- {t('pages:items.slot')}
-
-
-
- {entries.map((entry) => (
-
- ))}
-
-
-
-
+ >
+ )}
);
};
diff --git a/src/pages/Kill.tsx b/src/pages/Kill.tsx
index 46529e65..9cf8d424 100644
--- a/src/pages/Kill.tsx
+++ b/src/pages/Kill.tsx
@@ -100,7 +100,7 @@ export const Kill = (): ReactElement => {
const date = new Date(kill.time);
return (
-
+
diff --git a/src/pages/Quests.tsx b/src/pages/Quests.tsx
index 85afbb5a..30603bda 100644
--- a/src/pages/Quests.tsx
+++ b/src/pages/Quests.tsx
@@ -98,18 +98,8 @@ export const Quests = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return ;
- }
- if (error) {
- return ;
- }
- if (data?.quests?.nodes == null) {
- return ;
- }
-
- const entries = data.quests.nodes;
- const { pageInfo } = data.quests;
+ const entries = data?.quests?.nodes;
+ const { pageInfo } = data?.quests ?? {};
return (
@@ -124,169 +114,178 @@ export const Quests = (): ReactElement => {
-
-
-
- {t('pages:quests.search')}
- {
- search.set('name', event);
- setSearch(search);
- }}
- />
-
-
+
+
+ {t('pages:quests.search')}
+ {
+ search.set('name', event);
+ setSearch(search);
+ }}
+ />
+
-
-
-
-
- {t('pages:quests.name')}
- {t('pages:quests.xp')}
-
- {t('pages:quests.gold')}
-
- {t('pages:quests.given')}
- {t('pages:quests.choice')}
-
-
-
- {entries.map((quest) => (
-
-
-
-
-
-
-
-
-
-
- {quest.xp}
-
-
-
-
-
- {quest.rewardsGiven.slice(0, 5).map((reward) => (
-
-
}
- >
-
-
-
-
-
- {reward.count > 1 && (
-
}
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+
+
+
+
+ {t('pages:quests.name')}
+ {t('pages:quests.xp')}
+
+ {t('pages:quests.gold')}
+
+ {t('pages:quests.given')}
+ {t('pages:quests.choice')}
+
+
+
+ {entries.map((quest) => (
+
+
+
+
+
+
+
+
+
+
+ {quest.xp}
+
+
+
+
+
+ {quest.rewardsGiven.slice(0, 5).map((reward) => (
+
+
}
+ >
+
+
+
+
+
- {reward.count}
-
- )}
-
-
-
-
-
-
- ))}
-
- {quest.rewardsGiven.length > 5 && (
- {quest.rewardsGiven.length - 5} other items
- )}
-
-
- {quest.choiceCount > 0 && (
- Choose {quest.choiceCount}
- )}
+ src={reward.item.iconUrl}
+ alt={reward.item.name}
+ />
+ {reward.count > 1 && (
+
+ {reward.count}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ {quest.rewardsGiven.length > 5 && (
+ {quest.rewardsGiven.length - 5} other items
+ )}
+
+
+ {quest.choiceCount > 0 && (
+ Choose {quest.choiceCount}
+ )}
-
- {quest.rewardsChoice.slice(0, 5).map((reward) => (
-
-
}
- >
-
-
-
-
-
- {reward.count > 1 && (
-
+ {quest.rewardsChoice.slice(0, 5).map((reward) => (
+
+
}
+ >
+
+
+
+
+
- {reward.count}
-
- )}
-
-
-
-
-
-
- ))}
-
- {quest.rewardsChoice.length > 5 && (
- {quest.rewardsChoice.length - 5} other items
- )}
-
-
- ))}
-
-
-
-
+ src={reward.item.iconUrl}
+ alt={reward.item.name}
+ />
+ {reward.count > 1 && (
+
+ {reward.count}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ {quest.rewardsChoice.length > 5 && (
+ {quest.rewardsChoice.length - 5} other items
+ )}
+
+
+ ))}
+
+
+
+ )}
+ {entries != null && pageInfo && (
+
+ )}
);
};
diff --git a/src/pages/Scenario.tsx b/src/pages/Scenario.tsx
index c32c4451..469d0581 100644
--- a/src/pages/Scenario.tsx
+++ b/src/pages/Scenario.tsx
@@ -16,6 +16,7 @@ import { ScenarioHeatmap } from '@/components/scenario/ScenarioHeatmap';
import { ScenarioSkirmishes } from '@/components/scenario/ScenarioSkirmishes';
import type { ReactElement } from 'react';
import type { GetScenarioInfoQuery } from '@/__generated__/graphql';
+import { assetUrl } from '@/utils';
export const SCENARIO_SCOREBOARD_FRAGMENT = gql`
fragment ScenarioScoreboardEntry on ScenarioScoreboardEntry {
@@ -90,7 +91,7 @@ const SCENARIO_INFO = gql`
const ScenarioQueueTypes: Record
= {
0: 'Standard',
1: 'Group Ranked',
- 2: 'Discordant Skirmish',
+ 2: 'Random Scenario',
3: 'Unused',
4: 'City Siege',
5: 'Solo Ranked',
@@ -131,6 +132,21 @@ export const Scenario = ({
start: startDate,
}),
);
+ const orderEntries = scenario.scoreboardEntries.filter(
+ (entry) => entry.team === 0,
+ );
+ const destructionEntries = scenario.scoreboardEntries.filter(
+ (entry) => entry.team === 1,
+ );
+ const topDamage = scenario.scoreboardEntries.toSorted(
+ (left, right) => right.damage - left.damage,
+ )[0];
+ const topHealing = scenario.scoreboardEntries.toSorted(
+ (left, right) => right.healing - left.healing,
+ )[0];
+ const topProtection = scenario.scoreboardEntries.toSorted(
+ (left, right) => right.protection - left.protection,
+ )[0];
return (
@@ -149,7 +165,7 @@ export const Scenario = ({
-
+
@@ -180,7 +196,7 @@ export const Scenario = ({
+
+
+ Players
+
+ {orderEntries.length} Order · {destructionEntries.length}{' '}
+ Destruction
+
+
+
+ Top damage
+
+ {topDamage?.character.name ?? '—'} ·{' '}
+ {Number(topDamage?.damage ?? 0).toLocaleString()}
+
+
+
+ Top healing
+
+ {topHealing?.character.name ?? '—'} ·{' '}
+ {Number(topHealing?.healing ?? 0).toLocaleString()}
+
+
+
+ Top protection
+
+ {topProtection?.character.name ?? '—'} ·{' '}
+ {Number(topProtection?.protection ?? 0).toLocaleString()}
+
+
+
diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx
index 6078624a..31201687 100644
--- a/src/pages/Search.tsx
+++ b/src/pages/Search.tsx
@@ -112,24 +112,15 @@ export const Search = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return ;
- }
- if (error) {
- return ;
- }
- if (data?.search?.nodes == null) {
- return ;
- }
-
- const { pageInfo } = data.search;
+ const entries = data?.search?.nodes;
+ const { pageInfo } = data?.search ?? {};
const handleSubmit = (newQuery: string): void => {
void refetch({ first: perPage, query: newQuery });
};
return (
-
+
@@ -140,287 +131,271 @@ export const Search = (): ReactElement => {
-
-
-
-
-
-
- Name
- Info
- Type
-
-
-
- {data.search.nodes.map((searchItem) => {
- if (searchItem.__typename === 'Character') {
- return (
-
-
-
-
-
-
- {searchItem.name}
-
-
-
- {searchItem.guildMembership?.guild?.name}
-
-
-
-
- Lvl {searchItem.level}
+
+ {loading && entries == null && }
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+
+
+
+
+
+ Name
+ Info
+ Type
+
+
+
+ {entries.map((searchItem) => {
+ if (searchItem.__typename === 'Character') {
+ return (
+
+
+
+
+
+
+ {searchItem.name}
+
- RR {searchItem.renownRank}
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
+
+ {searchItem.guildMembership?.guild?.name}
+
+
+
+
+ Lvl {searchItem.level}
+
+ RR {searchItem.renownRank}
+
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
- if (searchItem.__typename === 'Guild') {
- return (
-
-
-
-
-
-
- {searchItem.name}
-
-
- Leader:{' '}
-
- {searchItem.leader?.name}
-
-
-
-
- Lvl {searchItem.level}
+ if (searchItem.__typename === 'Guild') {
+ return (
+
+
+
+
+
+
+ {searchItem.name}
+
- Members {searchItem.members?.totalCount}
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
-
- if (searchItem.__typename === 'Item') {
- return (
-
-
-
-
-
-
-
-
-
-
-
- {searchItem.name}
-
-
-
-
- {t(`enums:itemType.${searchItem.type}`)}{' '}
- {t(`enums:itemSlot.${searchItem.slot}`)}
-
-
-
- {searchItem.description}
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
+ Leader:{' '}
+
+ {searchItem.leader?.name}
+
+
+
+
+ Lvl {searchItem.level}
+
+ Members {searchItem.members?.totalCount}
+
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
- if (searchItem.__typename === 'Quest') {
- return (
-
-
-
-
-
+ if (searchItem.__typename === 'Item') {
+ return (
+
+
+
+
-
-
-
-
-
-
- {searchItem.name}
-
-
- {searchItem.minLevel > 0 && (
- Lvl: {searchItem.minLevel}
- )}
-
-
-
- {searchItem.journalEntry ?? searchItem.questDescription}
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
+
+
+
+
+
+
+ {searchItem.name}
+
+
+
+
+ {t(`enums:itemType.${searchItem.type}`)}{' '}
+ {t(`enums:itemSlot.${searchItem.slot}`)}
+
+
+
+ {searchItem.description}
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
- if (searchItem.__typename === 'Creature') {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {searchItem.name}
-
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
- if (searchItem.__typename === 'Chapter') {
- return (
-
-
-
-
-
-
+ if (searchItem.__typename === 'Quest') {
+ return (
+
+
+
+
+
-
-
-
-
-
-
-
- {searchItem.name}
-
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
+
+
+
+
+
+
+ {searchItem.name}
+
+
+ {searchItem.minLevel > 0 && (
+ Lvl: {searchItem.minLevel}
+ )}
+
+
+
+ {searchItem.journalEntry ??
+ searchItem.questDescription}
+
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
- /*
- If (searchItem.__typename === 'GameObject') {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {searchItem.name}
-
-
-
-
-
- {searchItem.__typename}
-
-
-
- );
- }
- */
- })}
-
-
-
-
+ if (searchItem.__typename === 'Creature') {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {searchItem.name}
+
+
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
+ if (searchItem.__typename === 'Chapter') {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {searchItem.name}
+
+
+
+
+
+ {searchItem.__typename}
+
+
+
+ );
+ }
+ })}
+
+
+
+ )}
+ {entries != null && pageInfo && (
+
+ )}
);
};
diff --git a/src/pages/SearchGuild.tsx b/src/pages/SearchGuild.tsx
index 060bde45..1477302c 100644
--- a/src/pages/SearchGuild.tsx
+++ b/src/pages/SearchGuild.tsx
@@ -63,24 +63,15 @@ export const SearchGuild = (): ReactElement => {
const { width } = useWindowDimensions();
const isMobile = width <= 768;
- if (loading) {
- return
;
- }
- if (error) {
- return
;
- }
- if (data?.guilds?.nodes == null) {
- return
;
- }
-
- const { pageInfo } = data.guilds;
+ const entries = data?.guilds?.nodes;
+ const { pageInfo } = data?.guilds ?? {};
const handleSubmit = (newQuery: string): void => {
void refetch({ first: perPage, query: newQuery });
};
return (
-
+
@@ -91,47 +82,62 @@ export const SearchGuild = (): ReactElement => {
-
-
-
-
-
- {t('pages:searchPageGuild.guild')}
- {t('pages:searchPageGuild.leader')}
- {t('pages:searchPageGuild.members')}
- {t('pages:searchPageGuild.level')}
-
-
-
- {data.guilds.nodes.map((guild) => (
-
-
- {guild.name}
-
-
-
- {guild.leader?.name}
-
-
- {guild.members?.totalCount}
- {guild.level}
-
- ))}
-
-
-
-
+ {loading && entries == null &&
}
+ {!loading && error && (
+
+ )}
+ {!loading && !error && entries == null && (
+
+ )}
+ {entries != null && (
+
+
+
+
+ {t('pages:searchPageGuild.guild')}
+ {t('pages:searchPageGuild.leader')}
+ {t('pages:searchPageGuild.members')}
+ {t('pages:searchPageGuild.level')}
+
+
+
+ {entries.map((guild) => (
+
+
+ {guild.name}
+
+
+
+ {guild.leader?.name}
+
+
+ {guild.members?.totalCount}
+ {guild.level}
+
+ ))}
+
+
+
+ )}
+ {entries != null && pageInfo && (
+
+ )}
);
};
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
index 34284238..b739ec75 100644
--- a/src/react-app-env.d.ts
+++ b/src/react-app-env.d.ts
@@ -1,2 +1,4 @@
+///
+
declare module '*.scss';
declare module '*.css';
diff --git a/src/style.scss b/src/style.scss
index 00ff68d1..508a0fdd 100644
--- a/src/style.scss
+++ b/src/style.scss
@@ -88,6 +88,47 @@ tr.scenario-scoreboard-row-team-1 {
border-bottom: 2px solid #000000;
}
+.scenario-scoreboard-teams {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.scenario-scoreboard-team {
+ border-left: 3px solid;
+ padding-left: 0.85rem;
+}
+
+.scenario-scoreboard-team-order {
+ border-color: rgb(12, 47, 158);
+}
+
+.scenario-scoreboard-team-destruction {
+ border-color: rgb(158, 12, 13);
+}
+
+.scenario-scoreboard-team-header {
+ align-items: center;
+ display: flex;
+ gap: 0.6rem;
+ margin-bottom: 0.75rem;
+}
+
+.scenario-scoreboard-team-header strong {
+ color: $text-strong;
+ font-size: 1.1rem;
+}
+
+.scenario-scoreboard-team-header span {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.85rem;
+}
+
+.scoreboard-stat-share {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.75rem;
+}
+
p.scenario-score-order {
color: rgba(12, 47, 158);
font-weight: bold;
@@ -108,6 +149,1207 @@ td.scenariolist-score-destruction {
font-weight: bold;
}
+.scenario-list-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.scenario-win-balance {
+ background: rgba(20, 20, 20, 0.45);
+ padding: 0.65rem 0.85rem;
+}
+
+.scenario-win-balance-totals {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.4rem;
+}
+
+.scenario-win-balance strong {
+ color: $text-strong;
+}
+
+.scenario-win-balance-bar {
+ background: rgb(158, 12, 13);
+ height: 6px;
+ position: relative;
+}
+
+.scenario-win-balance-bar > span {
+ background: rgb(12, 47, 158);
+ display: block;
+ height: 100%;
+}
+
+.scenario-win-balance-junction {
+ align-items: center;
+ display: flex;
+ gap: 0.45rem;
+ position: absolute;
+ top: -1.65rem;
+ transform: translateX(-50%);
+ white-space: nowrap;
+}
+
+.scenario-win-balance-junction strong {
+ font-size: 0.78rem;
+ text-shadow:
+ 0 1px 2px #000,
+ 0 0 3px #000;
+}
+
+.scenario-win-rate-order {
+ color: #6f91ff !important;
+}
+
+.scenario-win-rate-destruction {
+ color: #ff7474 !important;
+}
+
+.scenario-feed-toolbar {
+ align-items: center;
+ background: rgba(20, 20, 20, 0.45);
+ display: flex;
+ justify-content: space-between;
+ padding: 0.65rem 0.85rem;
+}
+
+.scenario-feed-toolbar strong,
+.scenario-feed-toolbar span {
+ display: block;
+}
+
+.scenario-feed-toolbar span {
+ font-size: 0.78rem;
+}
+
+.scenario-window-loading,
+.scenario-window-progress {
+ background: rgba(20, 20, 20, 0.55);
+ border-left: 3px solid $primary;
+ padding: 0.85rem;
+}
+
+.scenario-window-loading strong,
+.scenario-window-loading span {
+ display: block;
+}
+
+.scenario-window-loading span,
+.scenario-window-progress {
+ font-size: 0.8rem;
+}
+
+.scenario-window-loading .progress,
+.scenario-window-progress .progress {
+ margin-bottom: 0.55rem;
+}
+
+.scenario-list-summary > div {
+ background: rgba(50, 50, 50, 0.35);
+ border-top: 2px solid $primary;
+ padding: 0.75rem 1rem;
+ text-align: center;
+}
+
+.scenario-list-summary strong,
+.scenario-list-summary span {
+ display: block;
+}
+
+.scenario-list-summary strong {
+ color: $text-strong;
+ font-size: 1.35rem;
+}
+
+@media (max-width: 768px) {
+ .scenario-list-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .scenario-win-balance-junction {
+ gap: 0.2rem;
+ }
+}
+
+.scenario-roster-cell {
+ padding: 1rem !important;
+}
+
+.scenario-standouts-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.character-scenario-filters {
+ align-items: end;
+ background: rgba(20, 20, 20, 0.55);
+ display: grid;
+ gap: 0.65rem;
+ grid-template-columns: repeat(5, minmax(130px, 1fr)) auto;
+ padding: 0.75rem;
+}
+
+.character-scenario-filters label > span {
+ display: block;
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+}
+
+.character-scenario-filters .select {
+ height: auto;
+ width: 100%;
+}
+
+.character-scenario-filters select,
+.character-scenario-filters input {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ min-height: 34px;
+ padding: 0.35rem;
+ width: 100%;
+}
+
+.character-scenario-filters > button {
+ gap: 0.35rem;
+}
+
+.character-scenario-connections {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.character-scenario-connection {
+ background: rgba(20, 20, 20, 0.55);
+ border-top: 3px solid $primary;
+ min-width: 0;
+ padding: 0.75rem;
+}
+
+.character-scenario-connection > header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.6rem;
+}
+
+.character-scenario-connection h2 {
+ color: $text-strong;
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.character-scenario-connection header span {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.72rem;
+}
+
+.character-scenario-connection table {
+ font-size: 0.78rem;
+}
+
+.character-scenario-connection th,
+.character-scenario-connection td {
+ padding: 0.3em 0.25em !important;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.character-scenario-connection td:first-child {
+ width: 28px;
+}
+
+.character-scenario-connection td:first-child .image {
+ height: 24px;
+ width: 24px;
+}
+
+.scenario-standouts-grid .scenario-standouts table {
+ font-size: 0.74rem;
+ table-layout: fixed;
+ width: 100%;
+}
+
+.scenario-standouts-grid .scenario-standouts th,
+.scenario-standouts-grid .scenario-standouts td {
+ overflow: hidden;
+ padding: 0.3em 0.18em !important;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.scenario-standouts-grid .scenario-standouts th:first-child,
+.scenario-standouts-grid .scenario-standouts td:first-child {
+ width: 30px;
+}
+
+.scenario-standouts-grid .scenario-standouts th:nth-child(2),
+.scenario-standouts-grid .scenario-standouts td:nth-child(2) {
+ width: 102px;
+}
+
+.scenario-standouts-grid .scenario-standouts th:nth-child(3),
+.scenario-standouts-grid .scenario-standouts td:nth-child(3),
+.scenario-standouts-grid .scenario-standouts th:nth-child(4),
+.scenario-standouts-grid .scenario-standouts td:nth-child(4),
+.scenario-standouts-grid .scenario-standouts th:nth-child(7),
+.scenario-standouts-grid .scenario-standouts td:nth-child(7) {
+ width: 29px;
+}
+
+.scenario-standouts-grid .scenario-standouts th:nth-child(5),
+.scenario-standouts-grid .scenario-standouts td:nth-child(5) {
+ width: 38px;
+}
+
+.scenario-standouts-grid .scenario-standouts th:nth-child(n + 6),
+.scenario-standouts-grid .scenario-standouts td:nth-child(n + 6) {
+ width: 46px;
+}
+
+.scenario-standouts-grid .scenario-standouts .scenario-player-link {
+ display: block;
+ font-size: 0.78rem;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.scenario-standouts-grid .scenario-standouts td:first-child .image {
+ height: 24px;
+ width: 24px;
+}
+
+.scenario-breakdown {
+ background: rgba(20, 20, 20, 0.55);
+ border-top: 3px solid $primary;
+ padding: 0.75rem;
+}
+
+.scenario-breakdown > header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+}
+
+.scenario-breakdown h2 {
+ color: $text-strong;
+ font-size: 1.05rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.scenario-breakdown header p,
+.scenario-breakdown header > span {
+ color: rgba(255, 255, 255, 0.65);
+ font-size: 0.78rem;
+ margin: 0;
+}
+
+.scenario-breakdown-controls {
+ align-items: flex-end;
+ display: flex;
+ gap: 0.75rem;
+}
+
+.scenario-breakdown-controls label > span {
+ display: block;
+ font-size: 0.7rem;
+ margin-bottom: 0.15rem;
+}
+
+.scenario-breakdown-controls .select {
+ height: auto;
+ width: 100%;
+}
+
+.scenario-breakdown-controls select {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ min-width: 90px;
+ padding: 0.3rem;
+}
+
+.scenario-breakdown-controls > span {
+ color: rgba(255, 255, 255, 0.65);
+ font-size: 0.75rem;
+ padding-bottom: 0.35rem;
+ white-space: nowrap;
+}
+
+.scenario-breakdown-table-wrap {
+ overflow-x: auto;
+}
+
+.scenario-breakdown-table {
+ background: transparent;
+ min-width: 940px;
+}
+
+.scenario-breakdown-table th {
+ color: $primary;
+ white-space: nowrap;
+}
+
+.scenario-breakdown-table td {
+ vertical-align: middle;
+}
+
+.scenario-breakdown-sort {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ font-weight: inherit;
+ gap: 0.3rem;
+ padding: 0;
+ white-space: nowrap;
+}
+
+.scenario-breakdown-sort:hover,
+.scenario-breakdown-sort:focus-visible,
+.scenario-breakdown-sort i {
+ color: $text-strong;
+}
+
+.scenario-breakdown-toggle {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: $link;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ gap: 0.5rem;
+ padding: 0;
+ text-align: left;
+}
+
+.scenario-breakdown-toggle:hover,
+.scenario-breakdown-toggle:focus-visible {
+ color: $primary;
+ text-decoration: underline;
+}
+
+.scenario-breakdown-toggle i {
+ font-size: 0.7rem;
+ width: 0.75rem;
+}
+
+.scenario-breakdown-order {
+ color: rgb(72, 128, 255);
+}
+
+.scenario-breakdown-destruction {
+ color: rgb(235, 70, 70);
+}
+
+.scenario-breakdown-table small {
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.scenario-balance-status {
+ border: 1px solid;
+ border-radius: 999px;
+ display: inline-block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ min-width: 62px;
+ padding: 0.15rem 0.4rem;
+ text-align: center;
+}
+
+.scenario-balance-status-balanced {
+ background: rgba(48, 155, 86, 0.14);
+ border-color: rgb(48, 155, 86);
+ color: rgb(112, 220, 142);
+}
+
+.scenario-balance-status-watch {
+ background: rgba(217, 160, 25, 0.14);
+ border-color: rgb(217, 160, 25);
+ color: rgb(242, 194, 76);
+}
+
+.scenario-balance-status-lopsided {
+ background: rgba(201, 54, 54, 0.14);
+ border-color: rgb(201, 54, 54);
+ color: rgb(241, 100, 100);
+}
+
+.scenario-breakdown-matches > td {
+ background: rgba(0, 0, 0, 0.28);
+ padding: 0.75rem !important;
+}
+
+.scenario-breakdown-matches > td > div {
+ display: grid;
+ gap: 0.4rem;
+ grid-template-columns: repeat(3, minmax(220px, 1fr));
+}
+
+.scenario-breakdown-matches a {
+ background: rgba(20, 20, 20, 0.75);
+ border-left: 2px solid rgba(135, 123, 105, 0.55);
+ display: grid;
+ gap: 0.15rem;
+ padding: 0.5rem 0.65rem;
+}
+
+.scenario-breakdown-matches a:hover {
+ border-left-color: $primary;
+ background: rgba(35, 35, 35, 0.9);
+}
+
+.scenario-breakdown-matches a span {
+ color: rgba(255, 255, 255, 0.65);
+ font-size: 0.72rem;
+}
+
+.scenario-breakdown-match-score {
+ display: flex;
+ gap: 0.2rem;
+}
+
+.scenario-breakdown-matches a .scenario-breakdown-order {
+ color: rgb(72, 128, 255);
+ font-weight: 600;
+}
+
+.scenario-breakdown-matches a .scenario-breakdown-destruction {
+ color: rgb(235, 70, 70);
+ font-weight: 600;
+}
+
+.scenario-breakdown-matches p {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.75rem;
+ margin: 0;
+ padding: 0.5rem;
+}
+
+// Generic filter grid + result summary bar, matching the visual language
+// introduced on the Scenarios page (.scenario-standout-filters /
+// .scenario-active-filters below), but under page-agnostic names so any
+// page's filter area can share the same flat, cardless look instead of
+// each page inventing its own (a plain Bulma .card box on some pages, a
+// bare unstyled label+input on others).
+.filter-grid {
+ background: rgba(20, 20, 20, 0.55);
+ display: grid;
+ gap: 0.65rem;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ margin-bottom: 1.25rem;
+ padding: 0.75rem;
+}
+
+.filter-grid label > span {
+ display: block;
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+}
+
+.filter-grid .select {
+ height: auto;
+ width: 100%;
+}
+
+.filter-grid select {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ padding: 0.4rem;
+ width: 100%;
+}
+
+.filter-grid .field {
+ margin-bottom: 0 !important;
+}
+
+.filter-grid .control input.input {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+}
+
+.filter-summary-bar {
+ align-items: center;
+ background: rgba(20, 20, 20, 0.45);
+ border-left: 3px solid rgba(135, 123, 105, 0.7);
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.82rem;
+ margin-bottom: 1.25rem;
+ padding: 0.55rem 0.75rem;
+}
+
+@media (max-width: 520px) {
+ .filter-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+// Scrollable card for long lists (e.g. a creature's vendor items) that used
+// to be paginated 10-at-a-time. Keeps the whole filtered list in view
+// without pushing the rest of the page down.
+.vendor-items-scroll-box {
+ background: rgba(20, 20, 20, 0.45);
+ border: 1px solid rgba(135, 123, 105, 0.35);
+ border-radius: 4px;
+ max-height: 480px;
+ overflow-y: auto;
+}
+
+.vendor-items-scroll-box table {
+ margin-bottom: 0;
+}
+
+.vendor-items-scroll-box thead th {
+ background: #1a1a1a;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.scenario-standout-filters {
+ background: rgba(20, 20, 20, 0.55);
+ display: grid;
+ gap: 0.65rem;
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
+ padding: 0.75rem;
+}
+
+.scenario-standout-actions {
+ align-items: stretch;
+ display: flex;
+ flex-direction: column;
+}
+
+.scenario-standout-actions > span {
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+}
+
+.scenario-ranking-note {
+ background: rgba(20, 20, 20, 0.45);
+ border-left: 3px solid $primary;
+ font-size: 0.82rem;
+ padding: 0.65rem 0.85rem;
+}
+
+.scenario-active-filters {
+ align-items: center;
+ background: rgba(20, 20, 20, 0.45);
+ border-left: 3px solid rgba(135, 123, 105, 0.7);
+ display: flex;
+ justify-content: space-between;
+ padding: 0.55rem 0.75rem;
+}
+
+.scenario-active-filters > span {
+ font-size: 0.82rem;
+}
+
+.scenario-player-link {
+ background: none;
+ border: 0;
+ color: $link;
+ cursor: pointer;
+ font: inherit;
+ padding: 0;
+}
+
+.scenario-player-link:hover,
+.scenario-player-link:focus-visible {
+ color: $primary;
+ text-decoration: underline;
+}
+
+.scenario-standout-filters label > span {
+ display: block;
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+}
+
+.scenario-standout-filters .select {
+ height: auto;
+ width: 100%;
+}
+
+.scenario-standout-filters select {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ padding: 0.4rem;
+ width: 100%;
+}
+
+.scenario-standout-filters input[type='date'] {
+ background: rgba(20, 20, 20, 0.9);
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ min-height: 34px;
+ padding: 0.35rem;
+ width: 100%;
+}
+
+.scenario-modal-filters {
+ background: rgba(20, 20, 20, 0.8);
+ display: grid;
+ gap: 0.65rem;
+ grid-template-columns: repeat(5, minmax(130px, 1fr));
+ padding: 0.75rem;
+}
+
+.scenario-modal-filters label > span {
+ display: block;
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+}
+
+.scenario-modal-filters .select {
+ height: auto;
+ width: 100%;
+}
+
+.scenario-modal-filters select {
+ background: #171717;
+ border: 1px solid rgba(135, 123, 105, 0.4);
+ color: $text-strong;
+ padding: 0.4rem;
+ width: 100%;
+}
+
+@media (max-width: 900px) {
+ .scenario-standout-filters {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .scenario-breakdown-matches > td > div {
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
+ }
+
+ .scenario-modal-filters {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .character-scenario-filters {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .character-scenario-connections {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 520px) {
+ .scenario-standout-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .character-scenario-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .scenario-modal-filters {
+ grid-template-columns: 1fr;
+ }
+
+ .scenario-breakdown > header {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0.35rem;
+ }
+
+ .scenario-breakdown-controls {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0.35rem;
+ }
+
+ .scenario-breakdown-controls > span {
+ padding-bottom: 0;
+ }
+
+ .scenario-breakdown-matches > td > div {
+ grid-template-columns: 1fr;
+ }
+
+ .scenario-active-filters {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 0.55rem;
+ }
+}
+
+.scenario-standouts {
+ background: rgba(20, 20, 20, 0.55);
+ border-top: 3px solid;
+ min-width: 0;
+ padding: 0.75rem;
+}
+
+.scenario-standouts-team-0 {
+ border-color: rgb(12, 47, 158);
+}
+
+.scenario-standouts-team-1 {
+ border-color: rgb(158, 12, 13);
+}
+
+.scenario-standouts > header {
+ align-items: center;
+ display: flex;
+ gap: 0.65rem;
+ margin-bottom: 0.75rem;
+}
+
+.scenario-standouts-expand {
+ flex: 0 0 auto;
+}
+
+.scenario-standouts-mode {
+ align-items: center;
+ display: flex;
+ gap: 0.35rem;
+ margin-left: auto;
+}
+
+.scenario-standouts-mode > span {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.68rem !important;
+}
+
+.scenario-standouts-mode .select {
+ height: auto;
+ width: 100%;
+}
+
+.scenario-standouts-mode select {
+ background: rgba(20, 20, 20, 0.92);
+ border: 1px solid rgba(135, 123, 105, 0.45);
+ color: $text-strong;
+ font-size: 0.72rem;
+ padding: 0.25rem 0.35rem;
+}
+
+.scenario-standouts-modal .modal-card {
+ height: min(88vh, 900px);
+ position: relative;
+ width: min(94vw, 1500px);
+}
+
+.scenario-standouts-modal-close {
+ position: absolute;
+ right: 0.75rem;
+ top: 0.75rem;
+ z-index: 2;
+}
+
+.scenario-standouts-modal .modal-card-head {
+ background: #220606;
+ border-bottom-color: #7e0505;
+ padding-right: 3.25rem;
+}
+
+.scenario-standouts-modal .modal-card-head > div {
+ min-width: 0;
+}
+
+.scenario-standouts-modal .modal-card-title {
+ color: $text-strong;
+}
+
+.scenario-standouts-modal .modal-card-head p:not(.modal-card-title) {
+ font-size: 0.8rem;
+}
+
+.scenario-standouts-modal .modal-card-body {
+ background: #111;
+ padding: 1rem;
+}
+
+.scenario-standouts-modal .scenario-standouts {
+ min-width: 900px;
+}
+
+.scenario-player-modal .modal-card {
+ position: relative;
+ width: min(92vw, 720px);
+}
+
+.scenario-player-modal .modal-card-head {
+ background: #220606;
+ padding-right: 3.25rem;
+}
+
+.scenario-player-modal .modal-card-body {
+ background: #111;
+}
+
+.scenario-player-heading {
+ align-items: center;
+ display: flex;
+ gap: 0.75rem;
+}
+
+.scenario-player-heading p {
+ margin: 0;
+}
+
+.scenario-player-heading p:last-child {
+ font-size: 0.82rem;
+}
+
+.scenario-player-stats {
+ display: grid;
+ gap: 0.6rem;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.scenario-player-stats > div,
+.scenario-detail-highlights > div {
+ background: rgba(50, 50, 50, 0.35);
+ border-top: 2px solid $primary;
+ padding: 0.7rem;
+}
+
+.scenario-player-stats strong,
+.scenario-player-stats span,
+.scenario-detail-highlights strong,
+.scenario-detail-highlights span {
+ display: block;
+}
+
+.scenario-player-stats strong,
+.scenario-detail-highlights strong {
+ color: $text-strong;
+}
+
+.scenario-player-stats span,
+.scenario-detail-highlights span {
+ font-size: 0.75rem;
+}
+
+.scenario-player-averages {
+ background: rgba(20, 20, 20, 0.55);
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+}
+
+.scenario-detail-hero {
+ border-top: 3px solid $primary;
+}
+
+.scenario-detail-highlights {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.scenario-standouts-sort {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ font-weight: inherit;
+ gap: 0.3rem;
+ padding: 0;
+ white-space: nowrap;
+}
+
+.scenario-standouts-sort:hover,
+.scenario-standouts-sort:focus-visible {
+ color: $primary;
+}
+
+.scenario-standouts-sort .fa-caret-up,
+.scenario-standouts-sort .fa-caret-down {
+ color: $primary;
+}
+
+.scenario-standouts > header strong,
+.scenario-standouts > header span {
+ display: block;
+}
+
+.scenario-standouts > header strong {
+ color: $text-strong;
+ font-size: 1.1rem;
+}
+
+.scenario-standouts > header span {
+ font-size: 0.8rem;
+}
+
+@media (max-width: 1200px) {
+ .scenario-standouts-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .scenario-detail-highlights,
+ .scenario-player-stats {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .scenario-standouts-modal .modal-card {
+ height: 94vh;
+ width: 98vw;
+ }
+}
+
+@media (max-width: 420px) {
+ .scenario-detail-highlights,
+ .scenario-player-stats {
+ grid-template-columns: 1fr;
+ }
+}
+
+.scenario-roster-preview {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.scenario-roster {
+ background: rgba(0, 0, 0, 0.25);
+ border-top: 3px solid;
+ min-width: 0;
+ padding: 0.75rem;
+}
+
+.scenario-card-list {
+ display: grid;
+ gap: 1rem;
+}
+
+.scenario-match-card {
+ background: rgba(20, 20, 20, 0.62);
+ border: 1px solid rgba(135, 123, 105, 0.3);
+ border-left: 3px solid $primary;
+}
+
+.scenario-match-header {
+ align-items: center;
+ display: grid;
+ gap: 1rem;
+ grid-template-columns:
+ minmax(180px, 1.2fr) minmax(220px, 1.4fr)
+ minmax(260px, 1fr) auto;
+ padding: 1rem;
+}
+
+.scenario-match-title strong,
+.scenario-match-title span {
+ display: block;
+}
+
+.scenario-match-title strong {
+ color: $text-strong;
+ font-size: 1.15rem;
+}
+
+.scenario-match-title span {
+ font-size: 0.8rem;
+}
+
+.scenario-match-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.scenario-match-tags span {
+ background: rgba(50, 50, 50, 0.6);
+ border: 1px solid rgba(135, 123, 105, 0.25);
+ font-size: 0.72rem;
+ padding: 0.25rem 0.45rem;
+}
+
+.scenario-match-score {
+ display: grid;
+ gap: 0.4rem;
+ grid-template-columns: repeat(2, minmax(120px, 1fr));
+}
+
+.scenario-card-score {
+ align-items: center;
+ background: rgba(0, 0, 0, 0.3);
+ border-bottom: 2px solid transparent;
+ display: flex;
+ gap: 0.4rem;
+ padding: 0.35rem 0.5rem;
+}
+
+.scenario-card-score-order {
+ border-color: rgba(12, 47, 158, 0.75);
+}
+
+.scenario-card-score-destruction {
+ border-color: rgba(158, 12, 13, 0.75);
+}
+
+.scenario-card-score.is-winner {
+ background: rgba(240, 190, 40, 0.09);
+}
+
+.scenario-card-score span,
+.scenario-card-score strong {
+ display: block;
+}
+
+.scenario-card-score span {
+ font-size: 0.68rem;
+}
+
+.scenario-card-score strong {
+ color: $text-strong;
+ font-size: 1.15rem;
+}
+
+.scenario-match-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.scenario-match-rosters {
+ border-top: 1px solid rgba(135, 123, 105, 0.25);
+ padding: 1rem;
+}
+
+@media (max-width: 1100px) {
+ .scenario-match-header {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .scenario-match-header {
+ grid-template-columns: 1fr;
+ }
+
+ .scenario-match-actions .button {
+ flex: 1;
+ }
+}
+
+.scenario-roster-order {
+ border-color: rgb(12, 47, 158);
+}
+
+.scenario-roster-destruction {
+ border-color: rgb(158, 12, 13);
+}
+
+.scenario-roster summary {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ gap: 0.6rem;
+ list-style: none;
+ padding: 0.2rem;
+}
+
+.scenario-roster summary::-webkit-details-marker {
+ display: none;
+}
+
+.scenario-roster summary::after {
+ content: '▾';
+ margin-left: auto;
+}
+
+.scenario-roster:not([open]) summary::after {
+ content: '▸';
+}
+
+.scenario-roster summary strong,
+.scenario-roster summary span,
+.scenario-roster td small {
+ display: block;
+}
+
+.scenario-roster summary span,
+.scenario-roster td small {
+ font-size: 0.75rem;
+}
+
+.scenario-roster-content {
+ margin-top: 0.75rem;
+}
+
+.scenario-roster-totals {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.4rem;
+ margin-bottom: 0.75rem;
+}
+
+.scenario-role-composition {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.scenario-role-composition span {
+ border: 1px solid rgba(135, 123, 105, 0.35);
+ font-size: 0.72rem;
+ padding: 0.35rem;
+ text-align: center;
+}
+
+.scenario-role-composition strong {
+ color: $text-strong;
+}
+
+.scenario-role-heading th {
+ background: rgba(0, 0, 0, 0.45);
+ color: $text-strong;
+ padding-bottom: 0.45rem !important;
+ padding-top: 0.45rem !important;
+}
+
+.scenario-role-heading small {
+ color: $text;
+ display: block;
+ font-weight: normal;
+}
+
+.scenario-roster-totals span {
+ background: rgba(50, 50, 50, 0.35);
+ font-size: 0.75rem;
+ padding: 0.4rem;
+ text-align: center;
+}
+
+.scenario-roster-totals strong {
+ color: $text-strong;
+ display: block;
+}
+
+@media (max-width: 1024px) {
+ .scenario-roster-preview {
+ grid-template-columns: 1fr;
+ }
+}
+
tr.skirmish-scoreboard-row-realm-order {
background-color: rgba(12, 47, 158, 0.2);
border-bottom: 2px solid #000000;
diff --git a/src/utils.ts b/src/utils.ts
index 461f7950..2f583d28 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -2,85 +2,89 @@ import type { QuestTypeFlagsFlags } from '@/__generated__/graphql';
import {
Career,
KillDamageSourceType,
- QuestRepeatableType
+ QuestRepeatableType,
} from '@/__generated__/graphql';
+import { CreatureTitle } from '@/__generated__/schema-types';
+
+export const assetUrl = (path: string): string =>
+ `${import.meta.env.BASE_URL}${path.replace(/^\//, '')}`;
export const careerIcon = (career: Career): string => {
switch (career) {
case Career.Archmage: {
- return '/images/icons/archmage.png';
+ return assetUrl('/images/icons/archmage.png');
}
case Career.BlackGuard: {
- return '/images/icons/black-guard.png';
+ return assetUrl('/images/icons/black-guard.png');
}
case Career.BlackOrc: {
- return '/images/icons/black-orc.png';
+ return assetUrl('/images/icons/black-orc.png');
}
case Career.BrightWizard: {
- return '/images/icons/bright-wizard.png';
+ return assetUrl('/images/icons/bright-wizard.png');
}
case Career.Choppa: {
- return '/images/icons/choppa.png';
+ return assetUrl('/images/icons/choppa.png');
}
case Career.Chosen: {
- return '/images/icons/chosen.png';
+ return assetUrl('/images/icons/chosen.png');
}
case Career.DiscipleOfKhaine: {
- return '/images/icons/disciple-of-khaine.png';
+ return assetUrl('/images/icons/disciple-of-khaine.png');
}
case Career.Engineer: {
- return '/images/icons/engineer.png';
+ return assetUrl('/images/icons/engineer.png');
}
case Career.IronBreaker: {
- return '/images/icons/ironbreaker.png';
+ return assetUrl('/images/icons/ironbreaker.png');
}
case Career.KnightOfTheBlazingSun: {
- return '/images/icons/knight-of-the-blazing-sun.png';
+ return assetUrl('/images/icons/knight-of-the-blazing-sun.png');
}
case Career.Magus: {
- return '/images/icons/magus.png';
+ return assetUrl('/images/icons/magus.png');
}
case Career.Marauder: {
- return '/images/icons/marauder.png';
+ return assetUrl('/images/icons/marauder.png');
}
case Career.RunePriest: {
- return '/images/icons/rune-priest.png';
+ return assetUrl('/images/icons/rune-priest.png');
}
case Career.ShadowWarrior: {
- return '/images/icons/shadow-warrior.png';
+ return assetUrl('/images/icons/shadow-warrior.png');
}
case Career.Shaman: {
- return '/images/icons/shaman.png';
+ return assetUrl('/images/icons/shaman.png');
}
case Career.Slayer: {
- return '/images/icons/slayer.png';
+ return assetUrl('/images/icons/slayer.png');
}
case Career.Sorcerer: {
- return '/images/icons/sorcerer.png';
+ return assetUrl('/images/icons/sorcerer.png');
}
case Career.SquigHerder: {
- return '/images/icons/squig-herder.png';
+ return assetUrl('/images/icons/squig-herder.png');
}
case Career.SwordMaster: {
- return '/images/icons/sword-master.png';
+ return assetUrl('/images/icons/sword-master.png');
}
case Career.WarriorPriest: {
- return '/images/icons/warrior-priest.png';
+ return assetUrl('/images/icons/warrior-priest.png');
}
case Career.WhiteLion: {
- return '/images/icons/white-lion.png';
+ return assetUrl('/images/icons/white-lion.png');
}
case Career.WitchElf: {
- return '/images/icons/witch-elf.png';
+ return assetUrl('/images/icons/witch-elf.png');
}
case Career.WitchHunter: {
- return '/images/icons/witch-hunter.png';
+ return assetUrl('/images/icons/witch-hunter.png');
}
case Career.Zealot: {
- return '/images/icons/zealot.png';
+ return assetUrl('/images/icons/zealot.png');
}
default: {
- return '/images/icons/hidden.png';
+ return assetUrl('/images/icons/hidden.png');
}
}
};
@@ -95,13 +99,14 @@ export const variablesFromCursor = (
first: number | undefined;
last: number | undefined;
} => {
- if (type === 'after')
- {return {
+ if (type === 'after') {
+ return {
after: cursor,
before: undefined,
first: perPage,
last: undefined,
- };}
+ };
+ }
return {
after: undefined,
@@ -143,11 +148,13 @@ export const killDamageText = (killDamage: {
damageType: KillDamageSourceType;
ability?: { name?: string | null } | null;
}): string => {
- if (killDamage.damageType === KillDamageSourceType.FallDamage)
- {return 'Fall Damage';}
+ if (killDamage.damageType === KillDamageSourceType.FallDamage) {
+ return 'Fall Damage';
+ }
- if (killDamage.damageType === KillDamageSourceType.Other)
- {return 'Auto Attack';}
+ if (killDamage.damageType === KillDamageSourceType.Other) {
+ return 'Auto Attack';
+ }
return killDamage.ability?.name || 'Unknown';
};
@@ -196,3 +203,76 @@ export const questTypeIcon = (
return 'quest_green.png';
};
+
+export const creatureTitleIcon = (title: CreatureTitle): string | null => {
+ if (title === CreatureTitle.None) {
+ return null;
+ }
+
+ const value = title as string;
+
+ if (value.includes('HEAL') || value.includes('RITUALIST')) {
+ return assetUrl('/images/icons/healing.png');
+ }
+ if (value === 'BLACKSMITH') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_blacksmith.png');
+ }
+ if (
+ value.includes('MERCHANT') ||
+ value.includes('VENDOR') ||
+ value.includes('QUARTERMASTER')
+ ) {
+ return assetUrl('/images/corner_icons/ea_icon_corner_merchant.png');
+ }
+ if (value.includes('TRAINER')) {
+ return assetUrl('/images/corner_icons/ea_icon_corner_training.png');
+ }
+ if (value === 'APOTHECARY') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_apothecary.png');
+ }
+ if (value === 'AUCTIONEER') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_auction.png');
+ }
+ if (value === 'CULTIVATOR') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_cultivating.png');
+ }
+ if (value.includes('REGISTRAR') || value === 'HERALD') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_guild.png');
+ }
+ if (value === 'POSTMASTER') {
+ return assetUrl('/images/corner_icons/ea_icon_corner_mail.png');
+ }
+ if (value === 'BANKER' || value.includes('VAULT')) {
+ return assetUrl('/images/corner_icons/ea_icon_corner_bag.png');
+ }
+ if (
+ value.includes('GUARD') ||
+ value.includes('LORD') ||
+ value.includes('GENERAL') ||
+ value === 'DOGOF_WAR' ||
+ value === 'SERGEANT'
+ ) {
+ return assetUrl('/images/corner_icons/ea_icon_corner_rvr.png');
+ }
+
+ return null;
+};
+
+export const creatureTitleLabel = (title: CreatureTitle): string => {
+ if (title === CreatureTitle.None) {
+ return '';
+ }
+
+ return (title as string)
+ .split('_')
+ .flatMap((word) => {
+ // A handful of source enum values glue "OF" onto the previous word
+ // (e.g. ALTAROF_KHAINE_GUARD, DOGOF_WAR) - split those back apart.
+ if (word.length > 2 && word.endsWith('OF')) {
+ return [word.slice(0, -2), 'OF'];
+ }
+ return [word];
+ })
+ .map((word) => word.charAt(0) + word.slice(1).toLowerCase())
+ .join(' ');
+};
diff --git a/vite.config.ts b/vite.config.ts
index 33209a2e..d911ce2e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,15 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
+ base: mode === 'preview-host' ? '/killboard-preview/' : '/',
resolve: {
tsconfigPaths: true
},
plugins: [react()],
server: {
+ allowedHosts: ['terminal.local'],
+ host: '0.0.0.0',
port: 3000,
},
build: {
outDir: './build',
},
-});
+}));