cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s

- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly
- every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases
- cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163
- cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB)
- scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index
- skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts
- charts show points for short histories and pad sparse time axes
This commit is contained in:
2026-10-26 08:47:18 +00:00
parent 9d79dfb43a
commit 9e89a615c6
16 changed files with 44371 additions and 99 deletions

View File

@@ -1,26 +1,24 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import { kinds, overview, searchSkins, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
export const load: PageServerLoad = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const kind = url.searchParams.get('kind') ?? '';
const sort = url.searchParams.get('sort') ?? 'popular';
const [stats, dimensions, markets, skins] = await Promise.all([
overview(),
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
streams(),
searchSkins(q, kind, sort)
]);
return {
stats: stats[0] as {
items: number;
observations: string;
earliest: Date | null;
latest: Date | null;
},
stats,
dimensions: dimensions as unknown as { key: string; values: number }[],
markets: markets as unknown as { source: string; currency: string; kind: string }[]
markets,
kinds: kinds.map(([value, label]) => ({ value, label })),
browse: { q, kind, sort, skins }
};
};

View File

@@ -9,6 +9,70 @@
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// Browse: every skin, sticker, case, agent, … with its cheapest Skinport listing today.
const euro = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
type Hit = (typeof data.browse.skins)[number];
// svelte-ignore state_referenced_locally
let browseQuery = $state(data.browse.q);
// svelte-ignore state_referenced_locally
let browseKind = $state(data.browse.kind);
// svelte-ignore state_referenced_locally
let browseSort = $state(data.browse.sort);
// svelte-ignore state_referenced_locally
let hits = $state<Hit[]>(data.browse.skins);
let browsing = $state(false);
let moreLeft = $state(true);
let firstBrowse = true;
async function fetchHits(offset: number, signal?: AbortSignal) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
params.set('offset', String(offset));
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
if (!response.ok) throw new Error(`Search failed (${response.status})`);
return (await response.json()).skins as Hit[];
}
$effect(() => {
void browseQuery;
void browseKind;
void browseSort;
if (firstBrowse) {
firstBrowse = false;
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
browsing = true;
try {
hits = await fetchHits(0, controller.signal);
moreLeft = hits.length >= 60;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
if (browseQuery) params.set('q', browseQuery);
if (browseKind) params.set('kind', browseKind);
if (browseSort !== 'popular') params.set('sort', browseSort);
history.replaceState(history.state, '', params.size ? `?${params}` : location.pathname);
} catch {
/* aborted or failed; keep the last results */
} finally {
if (!controller.signal.aborted) browsing = false;
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
});
async function loadMore() {
browsing = true;
try {
const next = await fetchHits(hits.length);
hits = [...hits, ...next];
moreLeft = next.length >= 60;
} finally {
browsing = false;
}
}
type Result = {
id?: number;
name?: string;
@@ -40,15 +104,12 @@
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('steam-market|USD');
let kind = $state('sale-median');
let sourceChoice = $state('skinport|EUR');
let kind = $state('lowest-listing');
let markets = $derived(
data.markets.length
? data.markets
: [
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
]
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
@@ -90,7 +151,7 @@
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'sale-median';
'lowest-listing';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
@@ -115,8 +176,8 @@
}
const to = new SvelteDate();
const from =
range === 'all' && data.stats.earliest
? new SvelteDate(data.stats.earliest)
range === 'all' && data.stats.earliest_day
? new SvelteDate(data.stats.earliest_day)
: new SvelteDate(to);
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
if (range === '24h') from.setTime(to.getTime() - 86_400_000);
@@ -124,10 +185,12 @@
if (range === '30d') from.setUTCDate(from.getUTCDate() - 30);
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
if (range === 'all' && !data.stats.earliest) from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === 'all' && !data.stats.earliest_day) from.setUTCFullYear(from.getUTCFullYear() - 1);
return { from: from.toISOString(), to: to.toISOString() };
}
function bucketLabel(seconds: number) {
if (seconds >= 7 * 86400) return 'weekly';
if (seconds >= 86400) return 'daily';
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
}
@@ -256,19 +319,73 @@
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
Explore price histories for individual listings or compare groups of items over time.
Every CS2 skin, knife, glove, sticker, case and more, in every wear, StatTrak, Souvenir and
Doppler phase. Prices from Skinport, Steam, CSFloat and Buff163 are archived daily.
</p>
</div>
<div class="stats">
<div><strong>{number.format(data.stats.items)}</strong><span>listings indexed</span></div>
<div><strong>{number.format(data.stats.skins)}</strong><span>items</span></div>
<div><strong>{number.format(data.stats.configs)}</strong><span>configs</span></div>
<div>
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
>stored observations</span
<strong>{number.format(data.stats.latest_rows ?? 0)}</strong><span
>prices {data.stats.latest_day ?? ''}</span
>
</div>
</div>
</header>
<section class="browse" aria-label="Browse items">
<div class="browse-bar">
<Input
type="search"
bind:value={browseQuery}
placeholder="Search AK-47 Redline, Karambit Doppler, Katowice 2014…"
/>
<NativeSelect bind:value={browseSort}
><option value="popular">Most listed</option><option value="price">Most expensive</option
><option value="name">Name</option></NativeSelect
>
</div>
<div class="kinds" role="group" aria-label="Type">
<button class:on={!browseKind} onclick={() => (browseKind = '')}>All</button>
{#each data.kinds as k (k.value)}<button
class:on={browseKind === k.value}
onclick={() => (browseKind = browseKind === k.value ? '' : k.value)}>{k.label}</button
>{/each}
</div>
<div class="hits" class:dim={browsing}>
{#each hits as hit (hit.slug)}
<a
class="hit"
href={`/projects/cs-prices/${hit.slug}`}
style:--rarity={hit.rarity_color ?? 'transparent'}
>
{#if hit.image}<img
src={hit.image}
alt=""
loading="lazy"
width="96"
height="72"
/>{:else}<span class="noimg"></span>{/if}
<span class="hit-name">{hit.name}</span>
<span class="hit-meta"
>{hit.from_cents != null ? `from ${euro.format(hit.from_cents / 100)}` : 'no listings'} ·
{hit.configs}
config{hit.configs === 1 ? '' : 's'}</span
>
</a>
{:else}
<p class="none">{browsing ? 'Searching…' : 'Nothing matches.'}</p>
{/each}
</div>
{#if moreLeft && hits.length >= 60}<div class="more">
<Button variant="outline" size="sm" onclick={loadMore} disabled={browsing}>Show more</Button
>
</div>{/if}
</section>
<h2 class="section-title">Compare over time</h2>
<div class="workspace">
<aside class="picker" aria-label="Series selection">
<div class="panel-heading">
@@ -357,11 +474,10 @@
></label
><label
>Range<NativeSelect bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
value="7d">7 days</option
><option value="30d">30 days</option><option value="1y">1 year</option><option
value="5y">5 years</option
><option value="all">All history</option><option value="custom">Custom</option
><option value="7d">7 days</option><option value="30d">30 days</option><option
value="1y">1 year</option
><option value="5y">5 years</option><option value="all">All history</option><option
value="custom">Custom</option
></NativeSelect
></label
>
@@ -394,9 +510,9 @@
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
Group lines average each listing equally within a bucket; raw observations remain in the
database.
)} · {bucketSeconds ? `${bucketLabel(bucketSeconds)} points` : 'choose a series'}. Group
lines average each config equally per point. Open an item above for its own page with every
config.
</p>
</section>
</div>
@@ -436,6 +552,95 @@
h3 {
font-size: 0.85rem;
}
.browse {
margin-bottom: 2.5rem;
}
.browse-bar {
display: grid;
grid-template-columns: 1fr 170px;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.kinds {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.kinds button {
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-muted);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.74rem;
}
.kinds button:hover {
color: var(--site-text);
border-color: var(--site-text);
}
.kinds button.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.hits {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
transition: opacity 0.15s;
}
.hits.dim {
opacity: 0.55;
}
.hit {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.6rem 0.7rem 0.7rem;
border: 1px solid var(--site-border);
border-bottom: 2px solid var(--rarity);
background: var(--site-surface);
border-radius: 8px;
color: var(--site-text);
text-decoration: none;
transition:
transform 0.15s,
border-color 0.15s;
}
.hit:hover {
transform: translateY(-2px);
border-color: var(--site-text);
border-bottom-color: var(--rarity);
}
.hit img,
.noimg {
width: 100%;
height: 82px;
object-fit: contain;
}
.hit-name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.25;
}
.hit-meta {
color: var(--site-muted);
font-size: 0.68rem;
}
.none {
color: var(--site-muted);
font-size: 0.8rem;
}
.more {
display: flex;
justify-content: center;
margin-top: 0.8rem;
}
.section-title {
font-size: 1.25rem;
margin-bottom: 0.8rem;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
@@ -637,6 +842,15 @@
}
}
@media (max-width: 600px) {
.browse-bar {
grid-template-columns: 1fr 130px;
}
.hits {
grid-template-columns: repeat(2, 1fr);
}
.stats {
gap: 1.2rem;
}
.intro {
display: block;
}

View File

@@ -0,0 +1,23 @@
import { error } from '@sveltejs/kit';
import { loadSeries, loadSkin, pickConfig, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
// One skin (or sticker, case, agent, …) with every market config it has: wear, StatTrak,
// Souvenir and Doppler phase. The selected config comes from ?v=&wear=&phase= so every config
// has its own URL; its full daily history (EUR) is loaded here and the range is picked client-side.
export const load: PageServerLoad = async ({ params, url }) => {
const found = await loadSkin(params.slug);
if (!found) error(404, 'Not Found');
const config = pickConfig(found.configs, url.searchParams);
const [streamList, history] = await Promise.all([
streams(),
config ? loadSeries(config, null) : Promise.resolve(null)
]);
return {
skin: found.skin,
configs: found.configs,
selected: config?.id ?? null,
streams: streamList,
history
};
};

View File

@@ -0,0 +1,649 @@
<script lang="ts">
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
import { phaseOrder, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Config = (typeof data.configs)[number];
type Variant = 'normal' | 'stattrak' | 'souvenir';
const colors: Record<string, string> = {
'skinport:lowest-listing': '#3b82f6',
'skinport:sale-median-24h': '#22c55e',
'steam-market:sale-avg': '#a855f7',
'steam-market:lowest-listing': '#d946ef',
'csfloat:lowest-listing': '#f59e0b',
'buff163:lowest-listing': '#ef4444'
};
const sourceNames: Record<string, string> = {
skinport: 'Skinport',
'steam-market': 'Steam',
csfloat: 'CSFloat',
buff163: 'Buff163'
};
const kindNames: Record<string, string> = {
'lowest-listing': 'lowest listing',
'sale-median-24h': 'sold, 24h median',
'sale-avg': 'sold, average'
};
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
const count = new Intl.NumberFormat('nl-NL');
const money = (cents: number | null | undefined) =>
cents == null ? '–' : eur.format(cents / 100);
let streamById = $derived(new Map(data.streams.map((s) => [s.id, s])));
let selected = $derived(data.configs.find((c) => c.id === data.selected) ?? null);
const variantOf = (c: { stattrak: boolean; souvenir: boolean }): Variant =>
c.souvenir ? 'souvenir' : c.stattrak ? 'stattrak' : 'normal';
let variants = $derived(
(['normal', 'stattrak', 'souvenir'] as Variant[]).filter((v) =>
data.configs.some((c) => variantOf(c) === v)
)
);
let currentVariant = $derived(selected ? variantOf(selected) : 'normal');
let wears = $derived(
wearOrder.filter((w) =>
data.configs.some((c) => c.wear === w && variantOf(c) === currentVariant)
)
);
let phases = $derived(
phaseOrder.filter((p) =>
data.configs.some(
(c) =>
c.phase === p && variantOf(c) === currentVariant && c.wear === (selected?.wear ?? null)
)
)
);
/** Best price to show for a config: lowest EUR listing, else the latest sale. */
function best(config: Config) {
const listings = config.prices.filter(
(p) => streamById.get(p.stream)?.kind === 'lowest-listing'
);
const pool = listings.length ? listings : config.prices;
return pool.length ? Math.min(...pool.map((p) => p.eur_cents)) : null;
}
function find(v: Variant, wear: string | null, phase: string | null) {
return data.configs.find((c) => variantOf(c) === v && c.wear === wear && c.phase === phase);
}
function href(config: Config | undefined | null) {
if (!config) return '?';
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
params.set('v', variantOf(config));
if (config.wear) params.set('wear', wearShort[config.wear]);
if (config.phase) params.set('phase', config.phase);
return `?${params}`;
}
function forVariant(v: Variant) {
return (
find(v, selected?.wear ?? null, selected?.phase ?? null) ??
find(v, selected?.wear ?? null, null) ??
data.configs.find((c) => variantOf(c) === v && c.prices.length) ??
data.configs.find((c) => variantOf(c) === v)
);
}
function forWear(w: string) {
return find(currentVariant, w, selected?.phase ?? null) ?? find(currentVariant, w, null);
}
function configLabel(c: Config) {
const parts = [];
if (c.souvenir) parts.push('Souvenir');
if (c.stattrak) parts.push('StatTrak™');
if (c.wear) parts.push(c.wear);
if (c.phase) parts.push(c.phase);
return parts.join(' · ') || 'Standard';
}
function streamLabel(id: number, anyPhase = false) {
const s = streamById.get(id);
if (!s) return `Stream ${id}`;
return `${sourceNames[s.source] ?? s.source} ${kindNames[s.kind] ?? s.kind.replaceAll('-', ' ')}${anyPhase ? ' (any phase)' : ''}`;
}
let range = $state('all');
let cutoff = $derived.by(() => {
if (range === 'all') return 0;
const days = { '30d': 30, '90d': 90, '1y': 365 }[range] ?? 0;
return Date.now() - days * 86_400_000;
});
let series = $derived<PriceSeries[]>(
(data.history?.series ?? [])
.map((s) => {
const stream = streamById.get(s.stream);
return {
key: `${s.stream}:${s.anyPhase}`,
name: streamLabel(s.stream, s.anyPhase),
color: stream ? colors[`${stream.source}:${stream.kind}`] : undefined,
dashed: s.anyPhase,
points: s.points
.map(([day, cents]) => [Date.parse(day), cents] as [number, number])
.filter(([t]) => t >= cutoff)
};
})
.filter((s) => s.points.length)
);
let days = $derived(new Set(series.flatMap((s) => s.points.map(([t]) => t))).size);
let current = $derived(
(selected?.prices ?? [])
.map((p) => ({ ...p, stream: streamById.get(p.stream), id: p.stream }))
.sort((a, b) => a.eur_cents - b.eur_cents)
);
const spanOrder = ['24h', '7d', '30d', '90d'];
let windows = $derived(
[...(data.history?.windows ?? [])].sort(
(a, b) =>
a.source.localeCompare(b.source) || spanOrder.indexOf(a.span) - spanOrder.indexOf(b.span)
)
);
let windowSources = $derived([...new Set(windows.map((w) => w.source))]);
const fxCents = (cents: number | null, currency: string) =>
cents == null
? null
: currency === 'USD'
? Math.round(cents * (data.history?.fx?.rate ?? 0.92))
: cents;
let title = $derived(
selected && selected.market_hash_name !== data.skin.name
? `${selected.market_hash_name}${selected.phase ? ` (${selected.phase})` : ''}`
: data.skin.name
);
let image = $derived(selected?.icon_url ?? data.skin.image);
</script>
<svelte:head>
<title>{title} · CS prices · Stan Runge</title>
<meta
name="description"
content={`Daily ${data.skin.name} prices for every wear, StatTrak, Souvenir and phase, from Skinport, Steam, CSFloat and Buff163.`}
/>
</svelte:head>
<div class="cs-item">
<a class="back" href="/projects/cs-prices">← CS prices</a>
<header class="head">
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
<div class="title">
<p class="eyebrow">
<span class="rarity" style:--rarity={data.skin.rarity_color ?? 'var(--site-muted)'}></span>
{[data.skin.rarity, data.skin.category ?? data.skin.kind].filter(Boolean).join(' · ')}
</p>
<h1>{data.skin.name}</h1>
<p class="meta">
{#if data.skin.min_float != null}Float {data.skin.min_float.toFixed(
2
)}–{data.skin.max_float?.toFixed(2)} ·
{/if}{data.configs.length} config{data.configs.length === 1 ? '' : 's'}
{#if data.skin.collections?.length}
· {data.skin.collections.join(', ')}{/if}
</p>
</div>
{#if selected}<div class="price">
<strong>{money(best(selected))}</strong><span>{configLabel(selected)}</span>
</div>{/if}
</header>
{#if data.configs.length > 1}
<nav class="configs" aria-label="Config">
{#if variants.length > 1}
<div class="row">
{#each variants as v (v)}
{@const target = forVariant(v)}
<a
class="chip"
class:on={currentVariant === v}
class:st={v === 'stattrak'}
class:sv={v === 'souvenir'}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
>{v === 'normal' ? 'Normal' : v === 'stattrak' ? 'StatTrak™' : 'Souvenir'}</a
>
{/each}
</div>
{/if}
{#if wears.length}
<div class="row">
{#each wears as w (w)}
{@const target = forWear(w)}
<a
class="chip wear"
class:on={selected?.wear === w}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
title={w}><b>{wearShort[w]}</b><small>{target ? money(best(target)) : '–'}</small></a
>
{/each}
</div>
{/if}
{#if phases.length}
{@const any = find(currentVariant, selected?.wear ?? null, null)}
<div class="row">
{#if any}<a
class="chip"
class:on={!selected?.phase}
href={href(any)}
data-sveltekit-reset="false"
data-sveltekit-replacestate>Any phase</a
>{/if}
{#each phases as p (p)}
{@const target = find(currentVariant, selected?.wear ?? null, p)}
<a
class="chip phase"
class:on={selected?.phase === p}
data-phase={p}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
><b>{p.replace('Phase ', 'P')}</b><small>{target ? money(best(target)) : '–'}</small
></a
>
{/each}
</div>
{/if}
</nav>
{/if}
{#if current.length}
<section class="now" aria-label="Latest prices">
{#each current as p (p.id)}
<div
class="tile"
style:--accent={p.stream ? colors[`${p.stream.source}:${p.stream.kind}`] : undefined}
>
<span>{streamLabel(p.id)}</span>
<strong>{money(p.eur_cents)}</strong>
<small
>{p.stream?.currency === 'USD' ? `${usd.format(p.cents / 100)} · ` : ''}{p.volume !=
null
? `${count.format(p.volume)} ${p.stream?.kind === 'lowest-listing' ? 'listed' : 'sold'} · `
: ''}{p.day}</small
>
</div>
{/each}
</section>
{/if}
<section class="card chart-card" aria-label="Price history">
<div class="toolbar">
<h2>Price history <small>EUR · daily</small></h2>
<div class="ranges" role="group" aria-label="Range">
{#each ['30d', '90d', '1y', 'all'] as r (r)}
<button class:on={range === r} onclick={() => (range = r)}
>{r === 'all' ? 'All' : r}</button
>
{/each}
</div>
</div>
{#if series.length}
<PriceChart {series} kind="daily" currency="EUR" height="min(52vh, 420px)" zoom={days > 30} />
<p class="note">
{days} day{days === 1 ? '' : 's'} of history · USD sources at the ECB rate of each day{data
.history?.fx
? ` (now ${data.history.fx.rate.toFixed(4)} €/$)`
: ''} · dashed = price for any phase
</p>
{:else}
<div class="empty">
<strong>No prices yet for this config</strong>
<p>Prices are collected daily for every config; untraded ones stay empty.</p>
</div>
{/if}
</section>
{#if windows.length}
<section class="card" aria-label="Recent sales">
<h2>Recent sales</h2>
<div class="table-wrap">
<table>
<thead
><tr
><th>Source</th>{#each spanOrder as s (s)}<th>{s}</th>{/each}</tr
></thead
>
<tbody>
{#each windowSources as source (source)}
<tr>
<th
>{sourceNames[source] ?? source}{windows.find((w) => w.source === source)
?.anyPhase
? ' (any phase)'
: ''}</th
>
{#each spanOrder as span (span)}
{@const w = windows.find((x) => x.source === source && x.span === span)}
<td>
{#if w}
{money(fxCents(w.median_cents ?? w.avg_cents, w.currency))}
{#if w.volume}<small>{count.format(w.volume)} sold</small>{/if}
{:else}–{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="note">Skinport: median sale price and sales count. Steam: average sale price.</p>
</section>
{/if}
{#if data.configs.length > 1}
<section class="card" aria-label="All configs">
<h2>All configs</h2>
<div class="all">
{#each data.configs as c (c.id)}
<a
class="cfg"
class:on={c.id === data.selected}
href={href(c)}
data-sveltekit-reset="false"
data-sveltekit-replacestate><span>{configLabel(c)}</span><b>{money(best(c))}</b></a
>
{/each}
</div>
</section>
{/if}
</div>
<style>
.cs-item {
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(0.9rem, 4vw, 2.5rem) 4rem;
color: var(--site-text);
}
.back {
color: var(--site-muted);
font-size: 0.8rem;
text-decoration: none;
}
.back:hover {
color: var(--site-text);
}
.head {
display: flex;
align-items: center;
gap: 1.2rem;
margin: 1rem 0 1.2rem;
}
.head img {
width: 128px;
height: auto;
object-fit: contain;
flex: none;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.35));
}
.title {
flex: 1;
min-width: 0;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.66rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.45rem;
}
.rarity {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--rarity);
box-shadow: 0 0 10px var(--rarity);
}
h1 {
font-size: clamp(1.6rem, 4.5vw, 2.6rem);
line-height: 1.05;
letter-spacing: -0.05em;
margin: 0.35rem 0 0.4rem;
}
h2 {
font-size: 1rem;
letter-spacing: -0.03em;
margin-bottom: 0.7rem;
}
h2 small {
color: var(--site-muted);
font-weight: 500;
font-size: 0.72rem;
margin-left: 0.35rem;
}
.meta {
color: var(--site-muted);
font-size: 0.8rem;
}
.price {
text-align: right;
display: flex;
flex-direction: column;
}
.price strong {
font-size: 1.7rem;
letter-spacing: -0.05em;
}
.price span {
color: var(--site-muted);
font-size: 0.72rem;
}
.configs {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-text);
border-radius: 6px;
padding: 0.4rem 0.65rem;
font-size: 0.78rem;
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
background 0.15s;
}
.chip small {
color: var(--site-muted);
font-size: 0.7rem;
}
.chip:hover {
border-color: var(--site-text);
transform: translateY(-1px);
}
.chip.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.chip.on small {
color: inherit;
opacity: 0.75;
}
.chip.st:not(.on) {
color: #f97316;
}
.chip.sv:not(.on) {
color: #eab308;
}
.chip.phase[data-phase='Ruby'] b {
color: #ef4444;
}
.chip.phase[data-phase='Sapphire'] b {
color: #3b82f6;
}
.chip.phase[data-phase='Black Pearl'] b {
color: #a78bfa;
}
.chip.phase[data-phase='Emerald'] b {
color: #22c55e;
}
.chip.on.phase b {
color: inherit !important;
}
.now {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.tile {
border: 1px solid var(--site-border);
border-left: 3px solid var(--accent, var(--site-border));
background: var(--site-surface);
border-radius: 8px;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.tile span,
.tile small {
color: var(--site-muted);
font-size: 0.68rem;
}
.tile strong {
font-size: 1.15rem;
letter-spacing: -0.03em;
}
.card {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.ranges {
display: flex;
gap: 0.25rem;
margin-bottom: 0.7rem;
}
.ranges button {
border: 1px solid var(--site-border);
background: transparent;
color: var(--site-muted);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.72rem;
cursor: pointer;
}
.ranges button:hover {
color: var(--site-text);
}
.ranges button.on {
background: var(--site-text);
color: var(--site-bg);
}
.note {
color: var(--site-muted);
font-size: 0.7rem;
margin-top: 0.5rem;
}
.empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
text-align: center;
}
.empty p {
color: var(--site-muted);
font-size: 0.78rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th,
td {
text-align: right;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
th:first-child {
text-align: left;
}
thead th {
color: var(--site-muted);
font-weight: 600;
font-size: 0.7rem;
}
td small {
display: block;
color: var(--site-muted);
font-size: 0.66rem;
}
.all {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.3rem;
}
.cfg {
display: flex;
justify-content: space-between;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 6px;
font-size: 0.76rem;
color: var(--site-text);
text-decoration: none;
transition: border-color 0.15s;
}
.cfg:hover {
border-color: var(--site-text);
}
.cfg.on {
background: var(--site-hover);
border-color: var(--site-text);
}
.cfg span {
color: var(--site-muted);
}
@media (max-width: 640px) {
.head {
flex-wrap: wrap;
}
.head img {
width: 96px;
}
.price {
text-align: left;
width: 100%;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
}
.now {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -5,12 +5,14 @@ import type { RequestHandler } from './$types';
const maxSeries = 1000;
const maxDays = 365 * 20;
// Compare explorer: daily prices (cs.daily_prices) for up to 1000 configs or groups, bucketed per
// day, or per week for ranges over 400 days. Group lines average each config equally per bucket.
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
const source = typeof body.source === 'string' ? body.source : 'steam-market';
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
const kind = typeof body.kind === 'string' ? body.kind : 'lowest-listing';
const source = typeof body.source === 'string' ? body.source : 'skinport';
const currency = typeof body.currency === 'string' ? body.currency : 'EUR';
const ids = Array.isArray(body.ids) ? body.ids : [];
const values = Array.isArray(body.values) ? body.values : [];
if (
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
)
return Response.json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
return Response.json({ series: [], bucketSeconds: 0 });
return Response.json({ points: [], bucketSeconds: 0 });
const to = new Date(typeof body.to === 'string' ? body.to : Date.now());
const from = new Date(typeof body.from === 'string' ? body.from : to.getTime() - 365 * 86400_000);
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
return Response.json({ error: 'Invalid date range' }, { status: 400 });
// Aim for at most 240 buckets per series. Raw observations remain untouched.
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
const weekly = +to - +from > 400 * 86400_000;
const seconds = weekly ? 7 * 86400 : 86400;
const selection =
dimension === 'item'
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
@@ -44,22 +46,22 @@ export const POST: RequestHandler = async ({ request }) => {
sql`,`
)})`;
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
const bucket = weekly ? sql`date_trunc('week', p.day)::date` : sql`p.day`;
const rows = await db.execute(sql`
WITH per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id,
date_bin(make_interval(secs => ${seconds}), p.observed_at,
TIMESTAMPTZ '1970-01-01') AS bucket,
avg(p.price_cents) AS price_cents,
sum(p.volume) AS volume,
min(p.price_cents) AS min_cents,
max(p.price_cents) AS max_cents
FROM cs.price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz
WITH stream AS (
SELECT id FROM cs.price_streams
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}
), per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id, ${bucket} AS bucket,
avg(p.price_cents) AS price_cents, sum(p.volume) AS volume,
min(p.price_cents) AS min_cents, max(p.price_cents) AS max_cents
FROM cs.daily_prices p ${selection}
WHERE p.stream_id = (SELECT id FROM stream)
AND p.day >= ${from.toISOString().slice(0, 10)}::date
AND p.day <= ${to.toISOString().slice(0, 10)}::date
GROUP BY 1, 2, 3
)
SELECT series_key, bucket, round(avg(price_cents))::int AS price_cents,
SELECT series_key, bucket::text AS bucket, round(avg(price_cents))::int AS price_cents,
count(*)::int AS items, sum(volume)::bigint AS volume,
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
FROM per_item GROUP BY 1, 2 ORDER BY 1, 2`);

View File

@@ -0,0 +1,13 @@
import { searchSkins } from '#lib/server/cs-prices.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const skins = await searchSkins(
url.searchParams.get('q') ?? '',
url.searchParams.get('kind') ?? '',
url.searchParams.get('sort') ?? 'popular',
Number(url.searchParams.get('limit') ?? 60) || 60,
Number(url.searchParams.get('offset') ?? 0) || 0
);
return Response.json({ skins });
};