Show profiles for players without qualifying matches
CI / checks (push) Successful in 4m56s

This commit is contained in:
2026-10-10 13:50:53 -04:00
parent cc9c10226e
commit bd34c3c9e7
11 changed files with 271 additions and 87 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export interface PlayerProfile {
firstSeen: string;
lastSeen: string;
stats: PlayerStats;
records: PersonalBestRecords;
records: PersonalBestRecords | null;
maps: PlayerMapRecord[];
matches: PlayerMatch[];
matchesTotal: number;
+2 -8
View File
@@ -120,14 +120,8 @@ export function getMatchDetail(id: number): MatchDetail | null {
CASE WHEN p.has_stats = 1 THEN p.downs END AS downs,
CASE WHEN p.has_stats = 1 THEN p.revives END AS revives,
CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore,
(EXISTS (SELECT 1 FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6'))
AND EXISTS (SELECT 1 FROM leaderboard_match_players recorded
JOIN leaderboard_matches history ON history.id = recorded.match_id
WHERE recorded.guid = p.guid AND history.game IN ('t5', 't6') AND recorded.has_stats = 1
AND history.status = 'completed'
AND history.highest_round >= ${MIN_RECORDED_ROUND}
AND recorded.highest_round >= ${MIN_RECORDED_ROUND})) AS canViewProfile
EXISTS (SELECT 1 FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile
FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
)
+24 -2
View File
@@ -36,7 +36,30 @@ export function getPlayerProfile(
const { matchCount, ...stats } = handle
.prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`)
.get(guid) as PlayerStats & { matchCount: number };
if (!matchCount) return null;
const limit = 25;
if (!matchCount) {
return {
...player,
stats: {
highestRound: 0,
kills: 0,
roundsSurvived: 0,
headshots: 0,
revives: 0,
downs: 0,
gamesPlayed: 0,
gamesCompleted: 0,
bestKills: 0,
bestScore: 0
},
records: null,
maps: [],
matches: [],
matchesTotal: 0,
offset: 0,
limit
};
}
const historyParameters: Array<string | number> = [guid];
let historyScope = scope;
if (options.game) {
@@ -59,7 +82,6 @@ export function getPlayerProfile(
.get(...historyParameters) as { total: number }
).total
: matchCount;
const limit = 25;
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
const offset = Number.isSafeInteger(requestedOffset)
? Math.min(lastOffset, Math.max(0, requestedOffset))
@@ -35,7 +35,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL
Number(url.searchParams.get('offset') ?? 0),
options
);
if (!profile) error(404, 'No recorded stats found for this player');
if (!profile) error(404, 'Player not found');
return {
profile,
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
@@ -156,6 +156,12 @@
onRetry={() => void refresh()}
/>{/snippet}
</SectionHeader>
{#if !profile.records}
<EmptyState
title="No qualifying match history yet"
description="Stats appear after a recorded game ends, provided this player was observed at round 2 or later. Games in progress and interrupted games do not contribute."
/>
{/if}
<nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a
@@ -185,12 +191,13 @@
><a href={scopedLeaderboard}>Selected leaderboard</a>
</div>
<div class="stat">
<span>Lifetime best round</span><strong>{number.format(stats.highestRound)}</strong
><RecordSource
evidence={profile.records.highestRound}
{returnTo}
metric="Lifetime best round"
/>
<span>Lifetime best round</span><strong
>{profile.records ? number.format(stats.highestRound) : '—'}</strong
>{#if profile.records}<RecordSource
evidence={profile.records.highestRound}
{returnTo}
metric="Lifetime best round"
/>{/if}
</div>
<div class="stat">
<span>Lifetime games played</span><strong>{number.format(stats.gamesPlayed)}</strong
@@ -219,54 +226,60 @@
map. Tied records share a rank. Each record links to the match that established it.
</p>
</div>
<div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div
class="table-scroll desktop-records"
role="region"
aria-label="Per-map player records"
tabindex="0"
>
<table>
<thead
><tr
><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th
scope="col">Total rounds</th
></tr
></thead
>
<tbody
>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr
><th scope="row"
><a
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}
>{map.name}</a
><small>{gameName(map.game)}</small></th
>{#each records as record}<td
{#if profile.maps.length}
<div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div
class="table-scroll desktop-records"
role="region"
aria-label="Per-map player records"
tabindex="0"
>
<table>
<thead
><tr
><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th
><th scope="col">Total rounds</th></tr
></thead
>
<tbody
>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr
><th scope="row"
><a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
$page.url
)}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt
? $timeFormat.dateTime(map.records[record.key].endedAt!)
: 'Completed match'}>{number.format(map[record.key])}</a
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
></td
>{/each}<td>{number.format(map.gamesPlayed)}</td><td
>{number.format(map.kills)}</td
><td>{number.format(map.roundsSurvived)}</td></tr
>{/each}</tbody
>
</table>
</div>
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}
>{map.name}</a
><small>{gameName(map.game)}</small></th
>{#each records as record}<td
><a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
$page.url
)}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt
? $timeFormat.dateTime(
map.records[record.key].endedAt!
)
: 'Completed match'}
>{number.format(map[record.key])}</a
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
></td
>{/each}<td>{number.format(map.gamesPlayed)}</td><td
>{number.format(map.kills)}</td
><td>{number.format(map.roundsSurvived)}</td></tr
>{/each}</tbody
>
</table>
</div>
{:else}
<EmptyState headingLevel={3} title="No map records yet" />
{/if}
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading">
@@ -333,6 +346,10 @@
>
</div>
</div>
{:else if !profile.records}<EmptyState
headingLevel={3}
title="No qualifying matches recorded"
/>
{:else}<EmptyState
headingLevel={3}
title="No completed matches in this selection"
@@ -352,7 +369,12 @@
<dl class="metric-list">
{#each otherMetrics as metric}<div>
<dt>{metric.label}</dt>
<dd>{number.format(stats[metric.key])}</dd>
<dd>
{!profile.records &&
(metric.key === 'bestKills' || metric.key === 'bestScore')
? '—'
: number.format(stats[metric.key])}
</dd>
</div>{/each}{#each efficiencyMetrics as metric}<div>
<dt>{metric.label}</dt>
<dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd>