Files
website/src/lib/game-tracker/MatchHistory.svelte

527 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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