Add completed match detail pages
This commit is contained in:
@@ -15,3 +15,30 @@ export interface ServerMatch {
|
||||
durationMs: number | null;
|
||||
finalRound: number | null;
|
||||
}
|
||||
|
||||
export interface MatchPlayer {
|
||||
guid: string;
|
||||
name: string;
|
||||
hasStats: boolean;
|
||||
presentAtEnd: boolean;
|
||||
canViewProfile: boolean;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
// Combat counters and score are the highest reported values; rounds survived
|
||||
// counts observed progression. These are not a final scoreboard snapshot.
|
||||
// A player seen only at game_start has no observed gameplay stats.
|
||||
highestRound: number | null;
|
||||
roundsSurvived: number | null;
|
||||
kills: number | null;
|
||||
headshots: number | null;
|
||||
downs: number | null;
|
||||
revives: number | null;
|
||||
bestScore: number | null;
|
||||
}
|
||||
|
||||
export interface MatchDetail extends ServerMatch {
|
||||
status: 'completed';
|
||||
game: string;
|
||||
startObserved: boolean;
|
||||
players: MatchPlayer[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND, type ServerMatch } from '../../match-history.js';
|
||||
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type ServerMatch } from '../../match-history.js';
|
||||
|
||||
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
||||
const handle = db();
|
||||
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
|
||||
const parameters = server === undefined ? [] : [server];
|
||||
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
||||
const matches = handle.prepare(`SELECT m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||
const matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||
m.start_players AS startPlayers,
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||
@@ -15,8 +10,58 @@ export function serverMatchHistory(server?: string, limit = 25): { matches: Serv
|
||||
END AS endPlayers,
|
||||
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
||||
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
||||
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound
|
||||
FROM leaderboard_matches m ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
||||
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
|
||||
|
||||
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
||||
const handle = db();
|
||||
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
|
||||
const parameters = server === undefined ? [] : [server];
|
||||
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
||||
const matches = handle.prepare(`SELECT ${matchSummary} FROM leaderboard_matches m
|
||||
${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
||||
.all(...parameters, limit) as ServerMatch[];
|
||||
return { matches, total };
|
||||
}
|
||||
|
||||
/** Read durable match records without depending on a current server or scoreboard. */
|
||||
export function getMatchDetail(id: number): MatchDetail | null {
|
||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||
const handle = db();
|
||||
const match = handle.prepare(`SELECT ${matchSummary}, m.game, m.start_observed AS startObserved
|
||||
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
|
||||
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||
if (!match) return null;
|
||||
|
||||
const players = handle.prepare(`SELECT p.guid,
|
||||
COALESCE((SELECT identity.name FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')
|
||||
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
||||
(SELECT identity.name FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||
p.has_stats AS hasStats, p.present AS presentAtEnd,
|
||||
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
||||
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
||||
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
||||
CASE WHEN p.has_stats = 1 THEN p.kills END AS kills,
|
||||
CASE WHEN p.has_stats = 1 THEN p.headshots END AS headshots,
|
||||
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.highest_round >= ${MIN_RECORDED_ROUND}
|
||||
AND recorded.highest_round >= ${MIN_RECORDED_ROUND})) AS canViewProfile
|
||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`)
|
||||
.all(match.game, id) as Array<Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||
hasStats: number; presentAtEnd: number; canViewProfile: number;
|
||||
}>;
|
||||
return { ...match, startObserved: Boolean(match.startObserved), players: players.map(player => ({
|
||||
...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd),
|
||||
canViewProfile: Boolean(player.canViewProfile)
|
||||
})) };
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<Card padding="lg">
|
||||
<h2>Match history</h2>
|
||||
<p>Newest matches first. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||||
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||||
{#if error}
|
||||
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
|
||||
{/if}
|
||||
@@ -57,11 +57,17 @@
|
||||
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
|
||||
<tbody>
|
||||
{#each matches as match (match.id)}
|
||||
<tr>
|
||||
<tr class:completed={match.status === 'completed'}>
|
||||
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
|
||||
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
|
||||
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
|
||||
<td>{match.map}</td>
|
||||
<td>
|
||||
{#if match.status === 'completed'}
|
||||
<a class="match-link" href={`/matches/${match.id}`} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
|
||||
{match.map}<small>View match →</small>
|
||||
</a>
|
||||
{:else}{match.map}{/if}
|
||||
</td>
|
||||
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
|
||||
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
|
||||
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
|
||||
@@ -84,4 +90,9 @@
|
||||
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
th { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
|
||||
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
|
||||
.match-link:hover { text-decoration: underline; }
|
||||
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user