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

This commit is contained in:
2026-10-25 16:00:48 +00:00
parent 9114b5433b
commit 80803ef5ac
24 changed files with 30747 additions and 5 deletions

View 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>