games: automatic CS2 / PUBG / R6 match history — games.matches, match_players, seasons, snapshots, accounts; in-app poller (Leetify, FACEIT, Steam share codes, PUBG API, Ubisoft) every 20 min plus scripts/games-ingest.ts for backfills; match history, stats, maps and seasons on the per-game pages
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
This commit is contained in:
526
src/lib/game-tracker/MatchHistory.svelte
Normal file
526
src/lib/game-tracker/MatchHistory.svelte
Normal file
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import Activity from 'lucide-svelte/icons/activity';
|
||||
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import Swords from 'lucide-svelte/icons/swords';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import Gauge from 'lucide-svelte/icons/gauge';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
||||
import Medal from 'lucide-svelte/icons/medal';
|
||||
import type { GameHistory } from '#lib/server/games/history.js';
|
||||
|
||||
let { history, game }: { history: GameHistory; game: 'cs2' | 'pubg' | 'r6' } = $props();
|
||||
|
||||
const PAGE = 30;
|
||||
let shown = $state(PAGE);
|
||||
let mode = $state('all');
|
||||
|
||||
const SOURCE_NAMES: Record<string, string> = {
|
||||
leetify: 'Leetify',
|
||||
faceit: 'FACEIT',
|
||||
steam: 'Valve share codes',
|
||||
pubg: 'PUBG API',
|
||||
ubisoft: 'Ubisoft'
|
||||
};
|
||||
const tz = 'Europe/Amsterdam';
|
||||
const day = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const time = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const ago = (iso: string | null) => {
|
||||
if (!iso) return 'never';
|
||||
const m = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||
return m < 60
|
||||
? `${m} min ago`
|
||||
: m < 48 * 60
|
||||
? `${Math.round(m / 60)} h ago`
|
||||
: `${Math.round(m / 1440)} days ago`;
|
||||
};
|
||||
const mapName = (m: string | null) =>
|
||||
!m
|
||||
? '—'
|
||||
: m
|
||||
.replace(/^(de|cs|ar)_/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : '—');
|
||||
const ratio = (a: number | null, b: number | null) => (a != null && b ? (a / b).toFixed(2) : '—');
|
||||
const leetify = (r: number | null) =>
|
||||
r == null ? '—' : `${r >= 0 ? '+' : ''}${(r * 100).toFixed(1)}`;
|
||||
|
||||
const modes = $derived(['all', ...new Set(history.matches.map((m) => m.mode ?? 'other'))]);
|
||||
const list = $derived(
|
||||
mode === 'all' ? history.matches : history.matches.filter((m) => (m.mode ?? 'other') === mode)
|
||||
);
|
||||
const t = $derived(history.totals);
|
||||
const recent = $derived(history.matches.slice(0, 30).reverse());
|
||||
const byMap = $derived.by(() => {
|
||||
const acc: Record<string, { n: number; w: number; k: number; d: number }> = {};
|
||||
for (const m of list) {
|
||||
if (!m.map) continue;
|
||||
const key = mapName(m.map);
|
||||
const a = (acc[key] ??= { n: 0, w: 0, k: 0, d: 0 });
|
||||
a.n += m.matchCount;
|
||||
a.w += m.result === 'win' ? 1 : 0;
|
||||
a.k += m.kills ?? 0;
|
||||
a.d += m.deaths ?? 0;
|
||||
}
|
||||
return Object.entries(acc)
|
||||
.sort((x, y) => y[1].n - x[1].n)
|
||||
.slice(0, 6);
|
||||
});
|
||||
const currentRank = $derived(
|
||||
history.seasons.find((s) => s.source === 'ubisoft' && s.board.startsWith('ranked'))
|
||||
);
|
||||
const tiles = $derived.by(() => {
|
||||
const out: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
icon: typeof Activity;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
label: 'matches tracked',
|
||||
value: t.matches.toLocaleString('en-GB'),
|
||||
hint: t.first ? `since ${day.format(new Date(t.first))}` : undefined,
|
||||
icon: Activity,
|
||||
color: '#38bdf8'
|
||||
}
|
||||
];
|
||||
if (game === 'pubg') {
|
||||
out.push({
|
||||
label: 'chicken dinners',
|
||||
value: String(t.wins),
|
||||
hint: pct(t.wins, t.matches),
|
||||
icon: Trophy,
|
||||
color: '#facc15'
|
||||
});
|
||||
out.push({
|
||||
label: 'avg placement',
|
||||
value: t.placementAvg ? `#${t.placementAvg.toFixed(1)}` : '—',
|
||||
icon: Medal,
|
||||
color: '#a78bfa'
|
||||
});
|
||||
out.push({
|
||||
label: 'avg damage',
|
||||
value: t.damage != null && t.matches ? Math.round(t.damage / t.matches).toString() : '—',
|
||||
hint: `${ratio(t.kills, t.matches)} kills per match`,
|
||||
icon: Crosshair,
|
||||
color: '#f87171'
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
label: 'win rate',
|
||||
value: pct(t.wins, t.wins + t.losses),
|
||||
hint: `${t.wins}W ${t.losses}L`,
|
||||
icon: Trophy,
|
||||
color: '#facc15'
|
||||
});
|
||||
out.push({
|
||||
label: 'K/D',
|
||||
value: ratio(t.kills, t.deaths),
|
||||
hint: t.kills != null ? `${t.kills.toLocaleString('en-GB')} kills` : undefined,
|
||||
icon: Crosshair,
|
||||
color: '#f87171'
|
||||
});
|
||||
if (game === 'cs2')
|
||||
out.push({
|
||||
label: 'ADR · Leetify',
|
||||
value: t.damage != null && t.rounds ? (t.damage / t.rounds).toFixed(0) : '—',
|
||||
hint: `avg rating ${leetify(t.ratingAvg)}`,
|
||||
icon: Gauge,
|
||||
color: '#34d399'
|
||||
});
|
||||
else
|
||||
out.push({
|
||||
label: 'ranked',
|
||||
value: currentRank?.rank ?? '—',
|
||||
hint:
|
||||
currentRank?.rankPoints != null
|
||||
? `${currentRank.rankPoints} RP · peak ${currentRank.maxRank ?? '—'}`
|
||||
: undefined,
|
||||
icon: Medal,
|
||||
color: '#a78bfa'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mb-10">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="flex items-center gap-1.5 font-medium"
|
||||
><RefreshCw size={14} color="#38bdf8" aria-hidden="true" /> auto-tracked</span
|
||||
>
|
||||
{#each history.sources as s (s.source)}
|
||||
<span
|
||||
class={['chip', s.waiting && 'waiting', s.error && 'failing']}
|
||||
title={s.error ?? undefined}
|
||||
>
|
||||
{#if s.error}<TriangleAlert
|
||||
size={13}
|
||||
color="#f97316"
|
||||
aria-hidden="true"
|
||||
/>{:else if s.waiting}<KeyRound
|
||||
size={13}
|
||||
color="#a1a1aa"
|
||||
aria-hidden="true"
|
||||
/>{:else}<span class="dot"></span>{/if}
|
||||
{SOURCE_NAMES[s.source] ?? s.source}
|
||||
<span class="muted">
|
||||
{#if s.waiting}{s.needs.length
|
||||
? `needs ${s.needs.join(' + ')}`
|
||||
: 'not connected yet'}{:else}{s.matches} · {ago(s.lastSuccessAt)}{/if}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if history.matches.length}
|
||||
<ul class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each tiles as tile (tile.label)}
|
||||
<li class="stat">
|
||||
<span class="label"
|
||||
><tile.icon size={13} color={tile.color} aria-hidden="true" /> {tile.label}</span
|
||||
>
|
||||
<span class="value">{tile.value}</span>
|
||||
{#if tile.hint}<span class="hint">{tile.hint}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mb-6 grid gap-3 lg:grid-cols-[2fr_3fr]">
|
||||
<div class="panel p-4">
|
||||
<h3 class="panel-title">
|
||||
<Activity size={14} color="#38bdf8" aria-hidden="true" /> last {recent.length}
|
||||
</h3>
|
||||
<div class="form">
|
||||
{#each recent as m (m.id)}
|
||||
<span
|
||||
class={[
|
||||
'pip',
|
||||
m.result ?? 'none',
|
||||
game === 'pubg' && m.placement != null && m.placement <= 10 && 'top10'
|
||||
]}
|
||||
title={`${day.format(new Date(m.playedAt))} · ${mapName(m.map)} · ${m.result ?? (m.placement ? `#${m.placement}` : '')}`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel p-4">
|
||||
<h3 class="panel-title"><MapIcon size={14} color="#34d399" aria-hidden="true" /> maps</h3>
|
||||
<ul class="maps">
|
||||
{#each byMap as [name, a] (name)}
|
||||
<li>
|
||||
<span>{name}</span>
|
||||
<span class="muted">{a.n}</span>
|
||||
<span>{game === 'pubg' ? `${ratio(a.k, a.n)} k/m` : pct(a.w, a.n)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if history.seasons.length && game === 'r6'}
|
||||
<div class="panel mb-6 overflow-x-auto p-4">
|
||||
<h3 class="panel-title"><Medal size={14} color="#a78bfa" aria-hidden="true" /> seasons</h3>
|
||||
<table>
|
||||
<thead
|
||||
><tr><th>season</th><th>board</th><th>rank</th><th>peak</th><th>W–L</th><th>K/D</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each history.seasons as s (s.season + s.board)}
|
||||
<tr
|
||||
><td>{s.season}</td><td>{s.board}</td><td>{s.rank ?? '—'}</td><td
|
||||
>{s.maxRank ?? '—'}</td
|
||||
><td>{s.wins ?? 0}–{s.losses ?? 0}</td><td>{ratio(s.kills, s.deaths)}</td></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel overflow-x-auto p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<h3 class="panel-title mb-0">
|
||||
<Swords size={14} color="#f87171" aria-hidden="true" /> matches
|
||||
</h3>
|
||||
{#if modes.length > 2}
|
||||
<div class="ml-auto flex flex-wrap gap-1">
|
||||
{#each modes as m (m)}
|
||||
<button
|
||||
type="button"
|
||||
class={['filter', mode === m && 'on']}
|
||||
onclick={() => ((mode = m), (shown = PAGE))}>{m}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>when</th><th>mode</th><th>map</th><th>result</th><th>K / D / A</th>
|
||||
<th>{game === 'pubg' ? 'damage' : game === 'cs2' ? 'ADR' : 'RP'}</th>
|
||||
{#if game === 'cs2'}<th>rating</th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each list.slice(0, shown) as m (m.id)}
|
||||
<tr>
|
||||
<td class="when"
|
||||
><span>{day.format(new Date(m.playedAt))}</span>
|
||||
<span class="muted">{time.format(new Date(m.playedAt))}</span></td
|
||||
>
|
||||
<td><span class="badge">{m.mode ?? '—'}</span></td>
|
||||
<td>{mapName(m.map)}</td>
|
||||
<td>
|
||||
{#if game === 'pubg'}
|
||||
<span
|
||||
class={[
|
||||
'res',
|
||||
m.placement === 1
|
||||
? 'win'
|
||||
: m.placement != null && m.placement <= 10
|
||||
? 'top10'
|
||||
: 'none'
|
||||
]}>#{m.placement ?? '?'}</span
|
||||
>{#if m.teamCount}<span class="muted">/{m.teamCount}</span>{/if}
|
||||
{:else if m.source === 'ubisoft'}
|
||||
<span class={['res', m.result ?? 'none']}>{m.scoreOwn}W {m.scoreOpp}L</span>
|
||||
<span class="muted"
|
||||
>{m.matchCount} {m.matchCount === 1 ? 'match' : 'matches'}</span
|
||||
>
|
||||
{:else}
|
||||
<span class={['res', m.result ?? 'none']}>{m.result ?? '—'}</span>
|
||||
{#if m.scoreOwn != null}<span class="muted">{m.scoreOwn}–{m.scoreOpp}</span>{/if}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{m.kills ?? '–'} / {m.deaths ?? '–'} / {m.assists ?? '–'}</td>
|
||||
<td class="num">
|
||||
{#if game === 'pubg'}{m.damage ?? '—'}{:else if game === 'cs2'}{m.extra.adr ??
|
||||
'—'}{:else}{m.rating ?? '—'}{#if m.extra.rpDelta != null}<span class="muted">
|
||||
({Number(m.extra.rpDelta) >= 0 ? '+' : ''}{m.extra.rpDelta})</span
|
||||
>{/if}{/if}
|
||||
</td>
|
||||
{#if game === 'cs2'}
|
||||
<td class="num"
|
||||
>{m.ratingLabel === 'Leetify rating'
|
||||
? leetify(m.rating)
|
||||
: m.rating != null
|
||||
? `${m.rating} elo`
|
||||
: '—'}</td
|
||||
>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if list.length > shown}
|
||||
<button type="button" class="more mt-3" onclick={() => (shown += 50)}
|
||||
>show {Math.min(50, list.length - shown)} more</button
|
||||
>
|
||||
{:else if history.truncated}
|
||||
<p class="muted mt-3 text-sm">
|
||||
Showing the latest {history.matches.length}; older matches are stored too.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="panel p-6 text-[var(--site-muted)]">
|
||||
No matches synced yet. They appear here automatically once a source is connected.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chip.waiting {
|
||||
border-style: dashed;
|
||||
}
|
||||
.chip.failing {
|
||||
border-color: color-mix(in oklab, #f97316 55%, transparent);
|
||||
}
|
||||
.dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, #22c55e 25%, transparent);
|
||||
}
|
||||
.muted {
|
||||
color: var(--site-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.stat,
|
||||
.panel {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.stat .label,
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.panel-title {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.stat .value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat .hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.pip {
|
||||
width: 0.9rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in oklab, var(--foreground) 15%, transparent);
|
||||
}
|
||||
.pip.win {
|
||||
background: #22c55e;
|
||||
}
|
||||
.pip.loss {
|
||||
background: #ef4444;
|
||||
}
|
||||
.pip.draw {
|
||||
background: #a1a1aa;
|
||||
}
|
||||
.pip.top10 {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.maps li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3rem 4.5rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.maps li span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--site-muted);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
td {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-top: 1px solid color-mix(in oklab, var(--foreground) 7%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr {
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 4%, transparent);
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.badge {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.res {
|
||||
font-weight: 600;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.res.win {
|
||||
color: #22c55e;
|
||||
}
|
||||
.res.loss {
|
||||
color: #ef4444;
|
||||
}
|
||||
.res.top10 {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.res.draw,
|
||||
.res.none {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.filter,
|
||||
.more {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
.filter:hover,
|
||||
.more:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.filter.on {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter,
|
||||
.more,
|
||||
tbody tr {
|
||||
transition: none;
|
||||
}
|
||||
.filter:hover,
|
||||
.more:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user