ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s

This commit is contained in:
2026-09-25 00:44:06 +00:00
parent c757d0882a
commit abac077541
200 changed files with 6236 additions and 2311 deletions

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type PriceSeries = { key: string; name: string; points: [number, number][] };
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
$props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
);
function update() {
if (!chart) return;
const style = getComputedStyle(document.documentElement);
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 78, right: 20, top: 28, bottom: 72 },
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
splitLine: { show: false }
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
splitLine: { lineStyle: { color: border } }
},
dataZoom: [
{ type: 'inside' },
{
type: 'slider',
bottom: 12,
height: 18,
borderColor: border,
textStyle: { color: muted },
fillerColor: 'rgba(127,127,127,.15)'
}
],
tooltip:
series.length > 100
? { show: false }
: {
trigger: 'item',
backgroundColor: style.getPropertyValue('--site-surface').trim(),
borderColor: border,
textStyle: { color: foreground },
formatter: (params: { seriesName: string; value: [number, number] }) =>
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
data: item.points,
showSymbol: false,
connectNulls: false,
silent: series.length > 100,
lineStyle: {
width: series.length > 100 ? 0.7 : 1.8,
opacity: series.length > 100 ? 0.42 : 0.9
},
itemStyle: {
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
},
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
large: true
}))
},
true
);
}
onMount(() => {
let disposed = false;
let resize: ResizeObserver | undefined;
let theme: MutationObserver | undefined;
import('echarts/core').then(async (echarts) => {
const [
{ LineChart },
{ GridComponent, TooltipComponent, DataZoomComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
import('echarts/components'),
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
resize = new ResizeObserver(() => chart?.resize());
resize.observe(element);
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
});
return () => {
disposed = true;
resize?.disconnect();
theme?.disconnect();
chart?.dispose();
};
});
$effect(() => {
if (ready) update();
});
</script>
<div
class="chart"
bind:this={element}
role="img"
aria-label={`${series.length} ${kind} price series chart`}
></div>
<style>
.chart {
width: 100%;
height: min(62vh, 560px);
min-height: 320px;
}
</style>

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { steamDate } from './steam';
describe('Steam market history dates', () => {
it('keeps hourly timestamps and older daily timestamps in UTC', () => {
expect(steamDate('Sep 24 2024 01: +0')).toBe('2024-09-24T01:00:00.000Z');
expect(steamDate('Jan 08 2016')).toBe('2016-01-08T00:00:00.000Z');
});
it('rejects an unknown format', () => {
expect(() => steamDate('not a date')).toThrow('Unknown Steam date');
});
});

View File

@@ -0,0 +1,23 @@
/** Parse the English UTC labels returned by Steam's market history endpoint. */
export function steamDate(value: string): string {
const match = /^(\w{3}) (\d{1,2}) (\d{4})(?: (\d{1,2}):)?/.exec(value);
if (!match) throw new Error(`Unknown Steam date: ${value}`);
const month = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
].indexOf(match[1]);
if (month < 0) throw new Error(`Unknown Steam month: ${value}`);
return new Date(
Date.UTC(Number(match[3]), month, Number(match[2]), Number(match[4] ?? 0))
).toISOString();
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { dimensionsFromName } from './taxonomy';
describe('CS market dimensions', () => {
it('groups normal and StatTrak wear variants under the same gun and skin', () => {
const normal = dimensionsFromName('AK-47 | Redline (Field-Tested)');
const stattrak = dimensionsFromName('StatTrak™ AK-47 | Redline (Minimal Wear)');
expect(normal).toMatchObject({
category: 'weapon',
weapon_type: 'rifle',
weapon: 'AK-47',
skin: 'Redline',
weapon_skin: 'AK-47 | Redline',
wear: 'Field-Tested',
stattrak: 'no'
});
expect(stattrak).toMatchObject({
category: 'weapon',
weapon_type: 'rifle',
weapon: 'AK-47',
skin: 'Redline',
wear: 'Minimal Wear',
stattrak: 'yes'
});
});
it('recognizes knives, gloves, souvenir weapons, and non-weapon entries', () => {
expect(dimensionsFromName('★ Karambit | Doppler (Factory New)')).toMatchObject({
category: 'knife',
weapon: 'Karambit',
skin: 'Doppler'
});
expect(dimensionsFromName('★ Sport Gloves | Vice (Field-Tested)')).toMatchObject({
category: 'gloves',
weapon: 'Sport Gloves'
});
expect(dimensionsFromName('Souvenir AWP | Dragon Lore (Factory New)')).toMatchObject({
weapon: 'AWP',
souvenir: 'yes'
});
expect(dimensionsFromName('Sticker | Crown (Foil)')).toMatchObject({
category: 'sticker',
skin: 'Crown',
variant: 'Foil'
});
expect(dimensionsFromName('Revolution Case')).toMatchObject({ category: 'container' });
});
});

View File

@@ -0,0 +1,72 @@
export type Dimensions = Record<string, string>;
const weapons: Record<string, string> = Object.fromEntries(
Object.entries({
rifle: ['AK-47', 'AUG', 'FAMAS', 'Galil AR', 'M4A1-S', 'M4A4', 'SG 553', 'SG 556'],
sniper: ['AWP', 'G3SG1', 'SCAR-20', 'SSG 08'],
pistol: [
'CZ75-Auto',
'Desert Eagle',
'Dual Berettas',
'Five-SeveN',
'Glock-18',
'P2000',
'P250',
'R8 Revolver',
'Tec-9',
'USP-S'
],
smg: ['MAC-10', 'MP5-SD', 'MP7', 'MP9', 'P90', 'PP-Bizon', 'UMP-45'],
heavy: ['MAG-7', 'Nova', 'Sawed-Off', 'XM1014', 'M249', 'Negev']
}).flatMap(([type, names]) => names.map((name) => [name, type]))
);
const wearNames = new Set([
'Factory New',
'Minimal Wear',
'Field-Tested',
'Well-Worn',
'Battle-Scarred'
]);
/** Derive useful facets from a market hash name; source-provided facets can override these. */
export function dimensionsFromName(marketHashName: string): Dimensions {
const result: Dimensions = {};
let name = marketHashName;
const starred = name.startsWith('★ ');
if (starred) name = name.slice(2);
if (name.startsWith('StatTrak™ ')) {
result.stattrak = 'yes';
name = name.slice('StatTrak™ '.length);
} else result.stattrak = 'no';
if (name.startsWith('Souvenir ')) {
result.souvenir = 'yes';
name = name.slice('Souvenir '.length);
} else result.souvenir = 'no';
const match = /^(.+?) \| (.+?)(?: \(([^)]+)\))?$/.exec(name);
if (match && /^(Sticker|Patch|Graffiti|Sealed Graffiti|Charm)$/.test(match[1])) {
result.category = match[1].toLowerCase();
result.skin = match[2];
if (match[3]) result.variant = match[3];
} else if (match) {
const [, base, finish, suffix] = match;
result.weapon = base;
result.skin = finish;
result.weapon_skin = `${base} | ${finish}`;
if (suffix && wearNames.has(suffix)) result.wear = suffix;
else if (suffix) result.variant = suffix;
if (weapons[base]) {
result.category = 'weapon';
result.weapon_type = weapons[base];
} else if (/Gloves|Hand Wraps/i.test(base)) {
result.category = 'gloves';
result.weapon_type = 'gloves';
} else if (starred || /Knife|Bayonet|Kukri|Karambit/i.test(base)) {
result.category = 'knife';
result.weapon_type = 'knife';
} else result.category = 'other';
} else if (/Case|Capsule|Package|Container/.test(name)) result.category = 'container';
else if (/Music Kit/.test(name)) result.category = 'music kit';
else result.category = 'other';
return result;
}