All checks were successful
Deploy production / deploy (push) Successful in 1m6s
527 lines
14 KiB
Svelte
527 lines
14 KiB
Svelte
<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>
|