Skip to content

Commit 902e5b8

Browse files
authored
feature: rehaul player stats (#421)
1 parent 60f1a15 commit 902e5b8

10 files changed

Lines changed: 3051 additions & 871 deletions

File tree

components/PlayerEloHistoryDialog.vue

Lines changed: 192 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,24 @@ interface EloEntry {
3333
assists: number | null;
3434
}
3535
36+
type StatSource = "5stack" | "external";
37+
3638
const props = defineProps<{
3739
open: boolean;
3840
playerId: string | number | null;
3941
playerName?: string | null;
4042
defaultMode?: Mode;
4143
defaultRange?: RangeKey;
4244
excludeTournaments?: boolean;
45+
// Mirrors the profile's stat source so the drill-down shows the same world
46+
// (5Stack ELO vs External rank) instead of blending them.
47+
source?: StatSource;
4348
}>();
4449
50+
const sourceRef = computed<StatSource>(() =>
51+
props.source === "external" ? "external" : "5stack",
52+
);
53+
4554
const emit = defineEmits<{
4655
"update:open": [value: boolean];
4756
}>();
@@ -61,6 +70,18 @@ const selectedRange = ref<RangeKey>(props.defaultRange ?? "1y");
6170
const selectedMode = ref<Mode>(props.defaultMode ?? "all");
6271
const history = ref<EloEntry[]>([]);
6372
const premierHistory = ref<EloEntry[]>([]);
73+
const rankHistoryRows = ref<
74+
Array<{
75+
rank: number;
76+
rank_type: number;
77+
previous_rank: number | null;
78+
match_id: string | null;
79+
observed_at: string;
80+
}>
81+
>([]);
82+
const performanceRows = ref<Array<{ type: string; match_result: string | null }>>(
83+
[],
84+
);
6485
const loading = ref(false);
6586
let queryGen = 0;
6687
@@ -101,8 +122,8 @@ const ELO_HISTORY_QUERY = gql`
101122
}
102123
`;
103124
104-
const PREMIER_HISTORY_QUERY = gql`
105-
query PlayerPremierRankHistoryDrillDown(
125+
const RANK_HISTORY_QUERY = gql`
126+
query PlayerRankHistoryDrillDown(
106127
$where: player_premier_rank_history_bool_exp!
107128
$limit: Int!
108129
) {
@@ -112,12 +133,27 @@ const PREMIER_HISTORY_QUERY = gql`
112133
limit: $limit
113134
) {
114135
rank
136+
rank_type
137+
previous_rank
115138
match_id
116139
observed_at
117140
}
118141
}
119142
`;
120143
144+
// External win/loss for the stats strip — rank history carries no result.
145+
const PERFORMANCE_QUERY = gql`
146+
query PlayerPerformanceDrillDown(
147+
$where: v_player_match_performance_bool_exp!
148+
$limit: Int!
149+
) {
150+
v_player_match_performance(where: $where, limit: $limit) {
151+
type
152+
match_result
153+
}
154+
}
155+
`;
156+
121157
const sinceTimestamp = computed(() => {
122158
const r = ranges.find((x) => x.key === selectedRange.value);
123159
if (!r || r.days === null) return null;
@@ -156,48 +192,102 @@ async function fetchHistory() {
156192
if (!props.open || !props.playerId) {
157193
history.value = [];
158194
premierHistory.value = [];
195+
rankHistoryRows.value = [];
196+
performanceRows.value = [];
159197
return;
160198
}
161199
const gen = ++queryGen;
162200
loading.value = true;
163201
164-
const premierWhere: Record<string, any> = {
165-
steam_id: { _eq: props.playerId },
166-
// The history table now holds Competitive/Wingman rows too — keep this
167-
// drill-down Premier-only.
168-
rank_type: { _eq: 11 },
169-
};
170-
if (sinceTimestamp.value) {
171-
premierWhere.observed_at = { _gte: sinceTimestamp.value };
172-
}
173-
174202
try {
175-
const [eloRes, premierRes] = await Promise.all([
176-
client.query({
203+
if (sourceRef.value === "5stack") {
204+
// 5Stack ELO only.
205+
const eloRes = await client.query({
177206
query: ELO_HISTORY_QUERY,
178207
variables: { where: whereClause.value, limit: rangeLimit.value },
179208
fetchPolicy: "network-only",
180-
}),
181-
client.query({
182-
query: PREMIER_HISTORY_QUERY,
183-
variables: { where: premierWhere, limit: rangeLimit.value },
184-
fetchPolicy: "network-only",
185-
}),
186-
]);
187-
if (gen !== queryGen) {
188-
return;
209+
});
210+
if (gen !== queryGen) return;
211+
history.value = ((eloRes.data as any)?.v_player_elo ?? []) as EloEntry[];
212+
premierHistory.value = [];
213+
rankHistoryRows.value = [];
214+
performanceRows.value = [];
215+
} else {
216+
// External: Valve rank history (all rank types) + performance for W/L.
217+
const rankWhere: Record<string, any> = {
218+
steam_id: { _eq: props.playerId },
219+
};
220+
if (sinceTimestamp.value) {
221+
rankWhere.observed_at = { _gte: sinceTimestamp.value };
222+
}
223+
const perfWhere: Record<string, any> = {
224+
player_steam_id: { _eq: props.playerId },
225+
source: { _neq: "5stack" },
226+
};
227+
if (sinceTimestamp.value) {
228+
perfWhere.match_created_at = { _gte: sinceTimestamp.value };
229+
}
230+
if (props.excludeTournaments) {
231+
perfWhere.match = { is_tournament_match: { _eq: false } };
232+
}
233+
const [rankRes, perfRes] = await Promise.all([
234+
client.query({
235+
query: RANK_HISTORY_QUERY,
236+
variables: { where: rankWhere, limit: rangeLimit.value },
237+
fetchPolicy: "network-only",
238+
}),
239+
client.query({
240+
query: PERFORMANCE_QUERY,
241+
variables: { where: perfWhere, limit: rangeLimit.value },
242+
fetchPolicy: "network-only",
243+
}),
244+
]);
245+
if (gen !== queryGen) return;
246+
history.value = [];
247+
rankHistoryRows.value = ((rankRes.data as any)
248+
?.player_premier_rank_history ?? []) as typeof rankHistoryRows.value;
249+
performanceRows.value = ((perfRes.data as any)
250+
?.v_player_match_performance ?? []) as typeof performanceRows.value;
251+
// Premier (rank_type 11) series.
252+
let prev: number | null = null;
253+
premierHistory.value = rankHistoryRows.value
254+
.filter((r) => r.rank_type === 11)
255+
.map((r) => {
256+
const change = prev === null ? 0 : r.rank - prev;
257+
prev = r.rank;
258+
return {
259+
current_elo: r.rank,
260+
updated_elo: r.rank,
261+
elo_change: change,
262+
match_created_at: r.observed_at,
263+
match_id: r.match_id,
264+
match_result: null,
265+
type: "Premier",
266+
kills: null,
267+
deaths: null,
268+
assists: null,
269+
};
270+
});
189271
}
190-
history.value = ((eloRes.data as any)?.v_player_elo ?? []) as EloEntry[];
191-
192-
const rows = ((premierRes.data as any)?.player_premier_rank_history ??
193-
[]) as Array<{
194-
rank: number;
195-
match_id: string | null;
196-
observed_at: string;
197-
}>;
198-
let prev: number | null = null;
199-
premierHistory.value = rows.map((r) => {
200-
const change = prev === null ? 0 : r.rank - prev;
272+
} finally {
273+
if (gen === queryGen) {
274+
loading.value = false;
275+
}
276+
}
277+
}
278+
279+
// Competitive (12) / Wingman (6) Valve skill-group series from rank history.
280+
function buildRankSeries(rankType: number, type: string): EloEntry[] {
281+
let prev: number | null = null;
282+
return rankHistoryRows.value
283+
.filter((r) => r.rank_type === rankType)
284+
.map((r) => {
285+
const change =
286+
r.previous_rank != null
287+
? r.rank - Number(r.previous_rank)
288+
: prev === null
289+
? 0
290+
: r.rank - prev;
201291
prev = r.rank;
202292
return {
203293
current_elo: r.rank,
@@ -206,34 +296,42 @@ async function fetchHistory() {
206296
match_created_at: r.observed_at,
207297
match_id: r.match_id,
208298
match_result: null,
209-
type: "Premier",
299+
type,
210300
kills: null,
211301
deaths: null,
212302
assists: null,
213-
};
303+
} as EloEntry;
214304
});
215-
} finally {
216-
if (gen === queryGen) {
217-
loading.value = false;
218-
}
219-
}
220305
}
306+
const competitiveRank = computed(() => buildRankSeries(12, "Competitive"));
307+
const wingmanRank = computed(() => buildRankSeries(6, "Wingman"));
221308
222309
watch(
223310
() => [
224311
props.open,
225312
props.playerId,
226313
selectedRange.value,
227314
props.excludeTournaments,
315+
sourceRef.value,
228316
],
229317
() => {
230318
void fetchHistory();
231319
},
232320
{ immediate: true },
233321
);
234322
323+
// Keep the selected mode valid for the active source.
324+
watch(sourceRef, () => {
325+
const valid = modeOptions.value.map((m) => m.key);
326+
if (!valid.includes(selectedMode.value)) {
327+
selectedMode.value = sourceRef.value === "external" ? "Premier" : "all";
328+
}
329+
});
330+
235331
const filteredHistory = computed<EloEntry[]>(() => {
236-
if (selectedMode.value === "Premier") {
332+
if (sourceRef.value === "external") {
333+
if (selectedMode.value === "Competitive") return competitiveRank.value;
334+
if (selectedMode.value === "Wingman") return wingmanRank.value;
237335
return premierHistory.value;
238336
}
239337
if (selectedMode.value === "all") {
@@ -242,14 +340,26 @@ const filteredHistory = computed<EloEntry[]>(() => {
242340
return history.value.filter((e) => e.type === selectedMode.value);
243341
});
244342
343+
// Valve rank type for the chart's skill-group ladder (badges + integer steps).
344+
const chartRankType = computed<number | null>(() =>
345+
sourceRef.value === "external" &&
346+
(selectedMode.value === "Competitive" || selectedMode.value === "Wingman")
347+
? selectedMode.value === "Wingman"
348+
? 6
349+
: 12
350+
: null,
351+
);
352+
245353
const chartSeries = computed(() => {
246-
if (selectedMode.value === "Premier") {
247-
return premierHistory.value.length > 0
354+
// External: one rank series for the active mode (Premier / Comp / Wingman).
355+
if (sourceRef.value === "external") {
356+
const s = filteredHistory.value;
357+
return s.length > 0
248358
? [
249359
{
250-
key: "Premier",
251-
label: "Premier",
252-
history: premierHistory.value,
360+
key: selectedMode.value,
361+
label: selectedMode.value,
362+
history: s,
253363
focus: true,
254364
},
255365
]
@@ -292,10 +402,18 @@ const chartSeries = computed(() => {
292402
const stats = computed(() => {
293403
const list = filteredHistory.value;
294404
const headlineList =
295-
selectedMode.value === "all"
405+
sourceRef.value === "5stack" && selectedMode.value === "all"
296406
? history.value.filter((e) => e.type === "Competitive")
297407
: list;
298-
if (list.length === 0) {
408+
// Win/loss source: 5Stack reads it off the ELO entries; External has no
409+
// result on rank rows, so it comes from the performance query (by mode).
410+
const perfList: { match_result: string | null }[] =
411+
sourceRef.value === "external"
412+
? selectedMode.value === "Competitive" || selectedMode.value === "Wingman"
413+
? performanceRows.value.filter((p) => p.type === selectedMode.value)
414+
: performanceRows.value
415+
: list;
416+
if (list.length === 0 && perfList.length === 0) {
299417
return {
300418
current: null as number | null,
301419
peak: null as number | null,
@@ -333,10 +451,13 @@ const stats = computed(() => {
333451
}
334452
}
335453
336-
for (const e of list) {
454+
for (const e of perfList) {
337455
if (e.match_result === "won" || e.match_result === "win") wins++;
338456
else if (e.match_result === "lost" || e.match_result === "loss") losses++;
339457
else if (e.match_result === "tied" || e.match_result === "tie") ties++;
458+
}
459+
460+
for (const e of list) {
340461
if (typeof e.elo_change === "number") {
341462
changeSum += e.elo_change;
342463
changeCount++;
@@ -365,13 +486,26 @@ const stats = computed(() => {
365486
};
366487
});
367488
368-
const modeOptions = computed<{ key: Mode; label: string }[]>(() => [
369-
{ key: "all", label: t("pages.leaderboard.match_types.all") },
370-
{ key: "Competitive", label: t("pages.leaderboard.match_types.competitive") },
371-
{ key: "Wingman", label: t("pages.leaderboard.match_types.wingman") },
372-
{ key: "Duel", label: t("pages.leaderboard.match_types.duel") },
373-
{ key: "Premier", label: "Premier" },
374-
]);
489+
const modeOptions = computed<{ key: Mode; label: string }[]>(() =>
490+
sourceRef.value === "external"
491+
? [
492+
{ key: "Premier", label: "Premier" },
493+
{
494+
key: "Competitive",
495+
label: t("pages.leaderboard.match_types.competitive"),
496+
},
497+
{ key: "Wingman", label: t("pages.leaderboard.match_types.wingman") },
498+
]
499+
: [
500+
{ key: "all", label: t("pages.leaderboard.match_types.all") },
501+
{
502+
key: "Competitive",
503+
label: t("pages.leaderboard.match_types.competitive"),
504+
},
505+
{ key: "Wingman", label: t("pages.leaderboard.match_types.wingman") },
506+
{ key: "Duel", label: t("pages.leaderboard.match_types.duel") },
507+
],
508+
);
375509
376510
function fmtInt(n: number | null | undefined): string {
377511
if (n === null || n === undefined || !Number.isFinite(n)) return "";
@@ -622,7 +756,7 @@ function fmtDate(iso: string | null | undefined): string {
622756
</button>
623757
</div>
624758
<div v-else class="h-[360px] sm:h-[420px]">
625-
<PlayerEloChart :series="chartSeries" />
759+
<PlayerEloChart :series="chartSeries" :rank-type="chartRankType" />
626760
</div>
627761
</div>
628762

0 commit comments

Comments
 (0)