prices: fold groceries and CS skins into one price tracker (/trackers/prices) with tabs
Deploy production / deploy (push) Successful in 1m30s

- tab registry in #lib/price-trackers (add a tab = route + entry + icon)
- groceries -> /trackers/prices/groceries (owner-only), CS prices -> /trackers/prices/cs (public)
- old /trackers/groceries/* and /projects/cs-prices/* redirect (301 GET, 308 POST) with query
- nav: one prices entry replaces groceries and skins; cyan price-tag icon
This commit is contained in:
stan committed 2026-10-27 10:38:10 +00:00
1 parent c175c25615
commit 81b0c51672
29 files changed
+163 -28

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
// One price tracker, a tab per thing (#lib/price-trackers). Each tab keeps its own page,
// loader and data; this only adds the tab bar. Private tabs are only listed for me.
import { page } from '$app/state';
import Tag from 'lucide-svelte/icons/tag';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { visiblePriceTabs } from '#lib/price-trackers.js';
let { children } = $props();
const tabs = $derived(visiblePriceTabs(Boolean(page.data.isOwner)));
</script>
<SpottingSwitcher items={tabs} home="/trackers/prices" label="prices" homeIcon={Tag} />
{@render children()}
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { defaultPriceTab } from '#lib/price-trackers.js';
import type { PageServerLoad } from './$types';
// /trackers/prices has no page of its own: open the first tab this visitor can see.
export const load: PageServerLoad = (event) => {
redirect(307, defaultPriceTab(isOwner(event)).href);
};
@@ -0,0 +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 ({ 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`),
streams(),
searchSkins(q, kind, sort)
]);
return {
stats,
dimensions: dimensions as unknown as { key: string; values: number }[],
markets,
kinds: kinds.map(([value, label]) => ({ value, label })),
browse: { q, kind, sort, skins }
};
};
+872
View File
@@ -0,0 +1,872 @@
<script lang="ts">
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Badge } from '#lib/components/ui/badge/index.js';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
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(`/trackers/prices/cs/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;
market_hash_name?: string;
value?: string;
items?: number;
};
type Point = {
series_key: string;
bucket: string;
price_cents: number;
items: number;
volume: string | null;
min_cents: number;
max_cents: number;
};
const labels: Record<string, string> = {
item: 'Individual listing',
category: 'Category',
weapon_type: 'Weapon type',
weapon: 'Weapon name',
weapon_skin: 'Weapon and skin',
skin: 'Skin name',
wear: 'Wear',
stattrak: 'StatTrak',
souvenir: 'Souvenir',
variant: 'Variant',
steam_type: 'Steam type'
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('skinport|EUR');
let kind = $state('lowest-listing');
let markets = $derived(
data.markets.length
? data.markets
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
]);
let kinds = $derived(
markets.filter((market) => `${market.source}|${market.currency}` === sourceChoice)
);
$effect(() => {
if (!sourceOptions.includes(sourceChoice)) changeSource(sourceOptions[0]);
else if (kinds.length && !kinds.some((market) => market.kind === kind)) kind = kinds[0].kind;
});
let range = $state('1y');
let customFrom = $state('');
let customTo = $state('');
let query = $state('');
let results = $state<Result[]>([]);
let selected = $state<Result[]>([]);
let series = $state<PriceSeries[]>([]);
let searching = $state(false);
let loading = $state(false);
let adding = $state(false);
let error = $state('');
let searchError = $state('');
let bucketSeconds = $state(0);
function key(row: Result) {
return dimension === 'item' ? String(row.id) : (row.value ?? '');
}
function label(row: Result) {
return dimension === 'item' ? (row.name ?? '') : (row.value ?? '');
}
function changeDimension(value: string) {
dimension = value;
selected = [];
series = [];
query = '';
}
function changeSource(value: string) {
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'lowest-listing';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
selected = [...selected, row];
}
function remove(row: Result) {
selected = selected.filter((item) => key(item) !== key(row));
}
function dateRange() {
if (range === 'custom') {
if (
!customFrom ||
!customTo ||
Number.isNaN(Date.parse(customFrom)) ||
Number.isNaN(Date.parse(customTo))
)
return null;
return {
from: new SvelteDate(customFrom).toISOString(),
to: new SvelteDate(customTo).toISOString()
};
}
const to = new SvelteDate();
const from =
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);
if (range === '7d') from.setUTCDate(from.getUTCDate() - 7);
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_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`;
}
$effect(() => {
const d = dimension;
const q = query;
const controller = new AbortController();
const timer = setTimeout(async () => {
searching = true;
searchError = '';
try {
const response = await fetch(
`/trackers/prices/cs/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error(`Search failed (${response.status})`);
results = (await response.json()).results;
} catch (cause) {
if (!controller.signal.aborted) searchError = String(cause);
} finally {
if (!controller.signal.aborted) searching = false;
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
});
async function addMatches() {
adding = true;
searchError = '';
try {
const response = await fetch(
`/trackers/prices/cs/api/search?dimension=${encodeURIComponent(dimension)}&q=${encodeURIComponent(query)}&limit=1000`
);
if (!response.ok) throw new Error(`Search failed (${response.status})`);
const found = (await response.json()).results as Result[];
const all = new SvelteMap(selected.map((row) => [key(row), row]));
for (const row of found) if (all.size < 1000) all.set(key(row), row);
selected = [...all.values()];
} catch (cause) {
searchError = String(cause);
} finally {
adding = false;
}
}
$effect(() => {
const d = dimension;
const k = kind;
const [source, currency] = sourceChoice.split('|');
const selectedRange = dateRange();
const picked = selected.map((item) => ({ key: key(item), name: label(item) }));
const controller = new AbortController();
if (!picked.length) {
series = [];
error = '';
loading = false;
return;
}
if (!selectedRange) {
series = [];
error = 'Choose both dates for the custom range.';
loading = false;
return;
}
const timer = setTimeout(async () => {
loading = true;
error = '';
try {
const response = await fetch('/trackers/prices/cs/api/series', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
dimension: d,
kind: k,
source,
currency,
...selectedRange,
...(d === 'item'
? { ids: picked.map((item) => Number(item.key)) }
: { values: picked.map((item) => item.key) })
})
});
if (!response.ok)
throw new Error(
(await response.json()).error ?? `Chart request failed (${response.status})`
);
const payload = (await response.json()) as { points: Point[]; bucketSeconds: number };
const byKey = new SvelteMap<string, [number, number][]>();
for (const point of payload.points) {
const points = byKey.get(point.series_key) ?? [];
points.push([Date.parse(point.bucket), Number(point.price_cents)]);
byKey.set(point.series_key, points);
}
series = picked
.map((item) => ({ ...item, points: byKey.get(item.key) ?? [] }))
.filter((item) => item.points.length);
bucketSeconds = payload.bucketSeconds;
} catch (cause) {
if (!controller.signal.aborted) error = String(cause);
} finally {
if (!controller.signal.aborted) loading = false;
}
}, 100);
return () => {
clearTimeout(timer);
controller.abort();
};
});
</script>
<svelte:head>
<title>CS prices · Stan Runge</title>
<meta
name="description"
content="Explore historical Counter-Strike market prices by item, weapon, skin, wear and more."
/>
</svelte:head>
<div class="cs-page">
<header class="intro">
<div>
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
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.skins)}</strong><span>items</span></div>
<div><strong>{number.format(data.stats.configs)}</strong><span>configs</span></div>
<div>
<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={`/trackers/prices/cs/${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">
<h2>Compare</h2>
<Badge variant="secondary">{selected.length} / 1000</Badge>
</div>
<label class="field"
><span>Group by</span><NativeSelect
value={dimension}
onchange={(event) => changeDimension(event.currentTarget.value)}
>
<option value="item">Individual listing</option>
{#each data.dimensions as option (option.key)}<option value={option.key}
>{labels[option.key] ?? option.key.replaceAll('_', ' ')}</option
>{/each}
</NativeSelect></label
>
<label class="field"
><span
>Find {dimension === 'item'
? 'listings'
: (labels[dimension] ?? dimension).toLowerCase()}</span
>
<Input
type="search"
bind:value={query}
placeholder={dimension === 'item' ? 'AK-47 | Redline…' : 'Search groups…'}
/></label
>
<div class="result-actions">
<span>{searching ? 'Searching…' : 'First 50 matches'}</span><Button
variant="outline"
size="xs"
onclick={addMatches}
disabled={adding}>{adding ? 'Adding…' : 'Add up to 1000 matches'}</Button
>
</div>
{#if searchError}<p class="error">{searchError}</p>{/if}
<div class="results">
{#each results as result (key(result))}
<button
class="result"
onclick={() => add(result)}
disabled={selected.some((item) => key(item) === key(result)) || selected.length >= 1000}
>
<span
>{label(result)}{#if result.items != null}<small
>{number.format(result.items)} listings</small
>{/if}</span
><span aria-hidden="true">+</span>
</button>
{/each}
</div>
<div class="selected-heading">
<h3>Selected</h3>
{#if selected.length}<Button variant="ghost" size="xs" onclick={() => (selected = [])}
>Clear</Button
>{/if}
</div>
<div class="selected" aria-live="polite">
{#if !selected.length}<p>Choose a listing or group to begin.</p>{/if}
{#each selected as item (key(item))}<Button
variant="outline"
size="xs"
title={`Remove ${label(item)}`}
onclick={() => remove(item)}
><span>{label(item)}</span><span aria-hidden="true">×</span></Button
>{/each}
</div>
</aside>
<section class="visual" aria-label="Price history">
<div class="toolbar">
<div>
<p class="eyebrow">historical comparison</p>
<h2>Price history</h2>
</div>
<div class="controls">
<label
>Source<NativeSelect
value={sourceChoice}
onchange={(event) => changeSource(event.currentTarget.value)}
>{#each sourceOptions as option (option)}<option value={option}
>{option.replace('|', ' · ')}</option
>{/each}</NativeSelect
></label
><label
>Range<NativeSelect bind:value={range}
><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
>
</div>
</div>
{#if range === 'custom'}<div class="custom-range">
<label>From<Input type="datetime-local" bind:value={customFrom} /></label><label
>To<Input type="datetime-local" bind:value={customTo} /></label
>
</div>{/if}
<Tabs.Root bind:value={kind} class="tabs"
><Tabs.List class="mb-2"
>{#each kinds as market (market.kind)}<Tabs.Trigger value={market.kind}
>{market.kind.replaceAll('-', ' ')}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
{#if error}<p class="error">{error}</p>{/if}
{#if selected.length === 0}<div class="empty">
<strong>Build a comparison</strong>
<p>Search the catalog and add the items or groups you want to plot.</p>
</div>
{:else if loading}<div class="empty"><p>Loading price history…</p></div>
{:else if series.length === 0}<div class="empty">
<strong>No observations in this range</strong>
<p>Try another period or price type.</p>
</div>
{:else}<PriceChart {series} {kind} currency={sourceChoice.split('|')[1]} />{/if}
<p class="note">
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {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>
</div>
<style>
.cs-page {
max-width: 1600px;
margin: 0 auto;
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
color: var(--site-text);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 2rem;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.67rem;
font-weight: 700;
}
h1 {
font-size: clamp(2.8rem, 6vw, 5.2rem);
line-height: 1;
letter-spacing: -0.075em;
margin: 0.7rem 0 1rem;
}
h2 {
font-size: 1.25rem;
letter-spacing: -0.04em;
}
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;
font-size: 0.95rem;
}
.stats {
display: flex;
gap: 2.5rem;
white-space: nowrap;
}
.stats div {
display: flex;
flex-direction: column;
align-items: end;
}
.stats strong {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.06em;
}
.stats span {
color: var(--site-muted);
font-size: 0.69rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 335px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.picker,
.visual {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
}
.picker {
padding: 1.2rem;
}
.panel-heading,
.selected-heading,
.result-actions,
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.panel-heading {
margin-bottom: 1.4rem;
}
.result-actions {
color: var(--site-muted);
font-size: 0.73rem;
}
.field {
display: block;
margin-bottom: 1rem;
}
.field > span,
.controls label {
display: block;
color: var(--site-muted);
font-size: 0.72rem;
font-weight: 600;
margin-bottom: 0.45rem;
}
:global([data-slot='native-select-wrapper']) {
width: 100%;
border: 1px solid var(--site-border);
color: var(--site-text);
background: var(--site-bg);
border-radius: 5px;
padding: 0.65rem 0.7rem;
font-size: 0.86rem;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.42;
}
.result-actions {
margin: 0.3rem 0 0.65rem;
}
.results {
border: 1px solid var(--site-border);
border-radius: 5px;
max-height: 260px;
overflow: auto;
}
.result {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.7rem;
align-items: center;
background: transparent;
color: var(--site-text);
text-align: left;
border: 0;
border-bottom: 1px solid var(--site-border);
padding: 0.55rem 0.7rem;
font-size: 0.76rem;
line-height: 1.35;
}
.result:last-child {
border-bottom: 0;
}
.result:hover:not(:disabled) {
background: var(--site-hover);
}
.result small {
display: block;
color: var(--site-muted);
font-size: 0.68rem;
margin-top: 0.15rem;
}
.selected-heading {
margin: 1.35rem 0 0.55rem;
}
.selected {
max-height: 240px;
overflow: auto;
display: flex;
flex-wrap: wrap;
align-content: start;
gap: 0.3rem;
}
.selected p {
color: var(--site-muted);
font-size: 0.8rem;
}
.visual {
min-width: 0;
padding: 1.4rem;
}
.toolbar {
align-items: end;
margin-bottom: 1.1rem;
}
.toolbar h2 {
font-size: 1.7rem;
margin-top: 0.2rem;
}
.controls {
display: flex;
gap: 0.5rem;
}
.controls :global([data-slot='native-select-wrapper']) {
min-width: 140px;
}
.custom-range {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
}
.custom-range label {
color: var(--site-muted);
font-size: 0.72rem;
flex: 1;
}
.empty {
min-height: 350px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.4rem;
}
.empty strong {
font-size: 1.1rem;
}
.empty p,
.note {
color: var(--site-muted);
font-size: 0.75rem;
}
.note {
border-top: 1px solid var(--site-border);
margin-top: 1rem;
padding-top: 1rem;
}
.error {
color: #e88;
font-size: 0.75rem;
margin: 0.6rem 0;
}
@media (max-width: 900px) {
.workspace {
grid-template-columns: 1fr;
}
.intro {
align-items: start;
}
}
@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;
}
.stats {
margin-top: 1.5rem;
justify-content: start;
}
.stats div {
align-items: start;
}
.visual {
padding: 1rem;
}
.toolbar {
align-items: start;
flex-direction: column;
}
}
</style>
@@ -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
};
};
@@ -0,0 +1,653 @@
<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',
'steam-market:sale-median-history': '#14b8a6',
'csfloat:sale-avg-history': '#a3e635'
};
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',
'sale-median-history': 'sold, daily median (history)',
'sale-avg-history': 'sold, daily average (history)'
};
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="/trackers/prices/cs">← 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>
@@ -0,0 +1,23 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
const dimension = url.searchParams.get('dimension') ?? 'item';
const offset = Math.min(100_000, Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0));
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
if (dimension === 'item') {
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
FROM cs.items WHERE name ILIKE ${pattern} ESCAPE '\'
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
return Response.json({ results: rows });
}
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
FROM cs.item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
return Response.json({ results: rows });
};
@@ -0,0 +1,69 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
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 : '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 (
!/^[a-z][a-z0-9_]*$/.test(dimension) ||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(source) ||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(kind) ||
!/^[A-Z]{3}$/.test(currency) ||
ids.length > maxSeries ||
values.length > maxSeries ||
ids.some((id) => !Number.isSafeInteger(id) || id <= 0) ||
values.some((value) => typeof value !== 'string' || value.length > 200)
)
return Response.json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
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 });
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(
ids.map((id) => sql`${id}`),
sql`,`
)})`
: sql`JOIN cs.item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
AND d.value IN (${sql.join(
values.map((value) => sql`${value}`),
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 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::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`);
return Response.json({ bucketSeconds: seconds, currency, source, kind, points: rows });
};
@@ -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 });
};
@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only, like tasks and health: everyone else gets a plain 404. Form actions skip
// this load, so +page.server.ts checks again and hooks.server.ts blocks the prefix too.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};
@@ -0,0 +1,527 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { refreshGroupStats } from '#lib/server/groceries/import.js';
import { parseEuros } from '#lib/groceries.js';
import { displayName, fold, parseSize } from '#lib/server/groceries/normalize.js';
import { HOMEWARE_TOPS } from '#lib/server/groceries/taxonomy.js';
import {
expand,
queryWords,
rankedIds,
searchConds,
searchContext
} from '#lib/server/groceries/search.js';
import { deleteObject, groceryImageKey, uploadImage } from '#lib/server/r2.js';
import { fail } from '@sveltejs/kit';
import type { Sql } from 'postgres';
import type { Actions, PageServerLoad } from './$types';
const sql = () => db.$client as unknown as Sql;
const PAGE_SIZE = 30;
export type GroupRow = {
id: number;
name: string;
brand: string | null;
category: string | null;
unit: string | null;
favorite: boolean;
manual: boolean;
itemCount: number;
storeCount: number;
minUnitPriceCents: number | null;
imageUrl: string | null;
familyKey: string | null;
minPriceCents: number | null;
promoCount: number;
categoryId: number | null;
};
export type ManualRow = {
id: number;
groupId: number;
itemId: number | null;
storeId: number;
priceCents: number;
promo: boolean;
sizeText: string | null;
unit: string | null;
unitAmount: number | null;
unitPriceCents: number | null;
observedOn: string;
photoUrl: string | null;
note: string | null;
};
export type CategoryRow = {
id: number;
slug: string;
name: string;
parentId: number | null;
icon: string | null;
color: string | null;
isFood: boolean;
count: number;
};
export type ItemRow = {
id: number;
groupId: number;
storeId: number;
name: string;
brand: string | null;
sizeText: string | null;
unit: string | null;
unitAmount: number | null;
unitSource: string | null;
priceCents: number | null;
promoPriceCents: number | null;
promoLabel: string | null;
unitPriceCents: number | null;
promoUnitPriceCents: number | null;
gtin: string | null;
imageUrl: string | null;
url: string | null;
available: boolean;
matchMethod: string | null;
matchConfidence: number | null;
matchLocked: boolean;
categoryPath: string | null;
lastSeenAt: Date;
};
const ITEM_COLS = (s: Sql) => s`
i.id, i.group_id as "groupId", i.store_id as "storeId", i.name, i.brand, i.size_text as "sizeText",
i.unit, i.unit_amount::float8 as "unitAmount", i.unit_source as "unitSource", i.price_cents as "priceCents",
i.promo_price_cents as "promoPriceCents", i.promo_label as "promoLabel",
i.unit_price_cents as "unitPriceCents", i.promo_unit_price_cents as "promoUnitPriceCents", i.gtin,
i.image_url as "imageUrl", i.url, i.available, i.match_method as "matchMethod",
i.match_confidence as "matchConfidence", i.match_locked as "matchLocked",
i.category_path as "categoryPath", i.last_seen_at as "lastSeenAt"`;
const GROUP_COLS = (s: Sql) => s`
g.id, g.name, g.brand, g.category, g.unit, g.favorite, g.manual, g.item_count as "itemCount",
g.store_count as "storeCount", g.min_unit_price_cents as "minUnitPriceCents",
g.image_url as "imageUrl", g.family_key as "familyKey", g.min_price_cents as "minPriceCents",
g.promo_count as "promoCount", g.category_id as "categoryId"`;
/** Manual shelf prices with the group their item is in now (items can move between groups). */
const MANUAL_SELECT = (s: Sql) => s`
select m.id, coalesce(i.group_id, g0.merged_into_id, m.group_id) as "groupId", m.item_id as "itemId",
m.store_id as "storeId", m.price_cents as "priceCents", m.promo, m.size_text as "sizeText", m.unit,
m.unit_amount::float8 as "unitAmount", m.unit_price_cents as "unitPriceCents",
m.observed_on::text as "observedOn", m.photo_url as "photoUrl", m.note
from groceries.manual_prices m
left join groceries.items i on i.id = m.item_id
left join groceries.groups g0 on g0.id = m.group_id`;
/** Manual prices older than this stop counting for "cheapest" in the list (still in the detail). */
const MANUAL_FRESH_DAYS = 90;
const SORTS = ['', 'unit', 'price', 'promo', 'stores'] as const;
type Sort = (typeof SORTS)[number];
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const s = sql();
const p = event.url.searchParams;
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
const words = q ? expand(queryWords(q), (await searchContext(s)).synonyms) : [];
const storeSlug = p.get('store') ?? '';
const catSlug = p.get('cat') ?? '';
const sortParam = p.get('sort') ?? '';
const sort: Sort = (SORTS as readonly string[]).includes(sortParam) ? (sortParam as Sort) : '';
const favorites = p.get('fav') === '1';
const promo = p.get('promo') === '1';
const multi = p.get('multi') === '1';
// Homeware (Xenos décor, woks, …) is hidden unless asked for; drugstore/household stay.
const nonfood = p.get('nonfood') === '1';
const page = Math.max(1, Math.min(10_000, Number(p.get('page')) || 1));
const detailId = Number(p.get('g')) || null;
const stores = await s<
{ id: number; slug: string; name: string; color: string | null; items: number }[]
>`
select st.id, st.slug, st.name, st.color,
(select count(*)::int from groceries.items i where i.store_id = st.id and i.available) as items
from groceries.stores st where st.archived_at is null order by st.sort_order, st.name`;
const store = stores.find((x) => x.slug === storeSlug) ?? null;
const cats = await s<Omit<CategoryRow, 'count'>[]>`
select id, slug, name, parent_id as "parentId", icon, color, is_food as "isFood"
from groceries.categories order by sort_order, name`;
const cat = cats.find((c) => c.slug === catSlug) ?? null;
const catIds = cat
? cats.filter((c) => c.id === cat.id || c.parentId === cat.id).map((c) => c.id)
: [];
const topOf = (c: (typeof cats)[number]) =>
c.parentId ? cats.find((x) => x.id === c.parentId)! : c;
const homewareIds = cats
.filter((c) => (HOMEWARE_TOPS as readonly string[]).includes(topOf(c).slug))
.map((c) => c.id);
// Choosing a homeware category shows it even with the non-food toggle off.
const showNonfood = nonfood || (cat ? homewareIds.includes(cat.id) : false);
const conds = [s`g.merged_into_id is null`, s`(g.item_count > 0 or g.favorite)`];
conds.push(...searchConds(s, words));
if (store)
conds.push(s`(g.store_ids @> array[${store.id}]::int[] or exists (
select 1 from groceries.manual_prices m where m.group_id = g.id and m.store_id = ${store.id}))`);
if (favorites) conds.push(s`g.favorite`);
if (promo) conds.push(s`g.promo_count > 0`);
if (multi) conds.push(s`g.store_count >= 2`);
if (!showNonfood && homewareIds.length)
conds.push(
s`(g.is_food or g.category_id is null or not (g.category_id = any(${homewareIds}::int[])))`
);
const and = (list: typeof conds) => list.reduce((a, c) => s`${a} and ${c}`);
// Chip counts ignore the category filter itself (facets).
const facetWhere = and(conds);
const where = cat ? and([...conds, s`g.category_id = any(${catIds}::int[])`]) : facetWhere;
const relevance = q
? s`(lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc,`
: s``;
const order =
sort === 'unit'
? s`g.min_unit_price_cents asc nulls last, g.store_count desc`
: sort === 'price'
? s`g.min_price_cents asc nulls last, g.store_count desc`
: sort === 'promo'
? s`(g.promo_count > 0) desc, g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`
: sort === 'stores'
? s`g.store_count desc, g.item_count desc`
: s`g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`;
// With a search query the default and promo sorts use relevance ranking (search.ts):
// plain/basic products first, flavoured and specialised ones further down.
const relevant = words.length > 0 && (sort === '' || sort === 'promo');
const [groups, [{ total }]] = await Promise.all([
relevant
? rankedIds(s, where, q, words, { promoFirst: sort === 'promo' }).then(async (all) => {
const ids = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
if (!ids.length) return [] as GroupRow[];
const rows = await s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g where g.id = any(${ids}::int[])`;
const pos = new Map(ids.map((id, i) => [id, i]));
return rows.sort((a, b) => pos.get(a.id)! - pos.get(b.id)!);
})
: s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g where ${where}
order by ${order}, g.name, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
s<{ total: number }[]>`select count(*)::int as total from groceries.groups g where ${where}`
]);
const facetRows = await s<{ id: number | null; n: number }[]>`
select g.category_id as id, count(*)::int as n from groceries.groups g where ${facetWhere}
group by 1`;
const ids = groups.map((g) => g.id);
const items = ids.length
? await s<ItemRow[]>`
select ${ITEM_COLS(s)} from groceries.items i
where i.group_id = any(${ids}::int[]) and i.available
order by coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`
: [];
const manual = ids.length
? await s<ManualRow[]>`
${MANUAL_SELECT(s)}
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = any(${ids}::int[])
and m.observed_on >= current_date - ${MANUAL_FRESH_DAYS}::int
order by m.observed_on desc, m.id desc`
: [];
const [stats] = await s<
{
groups: number;
crossStore: number;
items: number;
grouped: number;
unitPriced: number;
promos: number;
}[]
>`
select
(select count(*)::int from groceries.groups where merged_into_id is null and item_count > 0) as groups,
(select count(*)::int from groceries.groups where merged_into_id is null and store_count >= 2) as "crossStore",
count(*)::int as items,
(count(*) filter (where g.store_count >= 2))::int as grouped,
(count(*) filter (where i.unit_price_cents is not null))::int as "unitPriced",
(count(*) filter (where i.promo_price_cents is not null or i.promo_label is not null))::int as promos
from groceries.items i left join groceries.groups g on g.id = i.group_id
where i.available`;
const runs = await s<
{ storeId: number; finishedAt: Date | null; status: string; fetched: number }[]
>`
select distinct on (store_id) store_id as "storeId", finished_at as "finishedAt", status, fetched
from groceries.import_runs order by store_id, started_at desc`;
const facet = new Map(facetRows.map((r) => [r.id, r.n]));
const categories: CategoryRow[] = cats.map((c) => ({
...c,
count:
(facet.get(c.id) ?? 0) +
(c.parentId
? 0
: cats.filter((x) => x.parentId === c.id).reduce((a, x) => a + (facet.get(x.id) ?? 0), 0))
}));
let detail = null;
if (detailId) {
const [group] = await s<(GroupRow & { notes: string | null })[]>`
select ${GROUP_COLS(s)}, g.notes from groceries.groups g where g.id = ${detailId}`;
if (group) {
const [dItems, similar, dManual] = await Promise.all([
s<ItemRow[]>`
select ${ITEM_COLS(s)} from groceries.items i where i.group_id = ${detailId}
order by i.available desc, coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`,
group.familyKey
? s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g
where g.family_key = ${group.familyKey} and g.id <> ${detailId}
and g.merged_into_id is null and g.item_count > 0
order by g.min_unit_price_cents nulls last limit 12`
: Promise.resolve([] as GroupRow[]),
s<ManualRow[]>`
${MANUAL_SELECT(s)}
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = ${detailId}
order by m.observed_on desc, m.id desc limit 100`
]);
const history = dItems.length
? await s<
{
itemId: number;
observedOn: string;
priceCents: number | null;
promoPriceCents: number | null;
promoLabel: string | null;
}[]
>`
select item_id as "itemId", observed_on::text as "observedOn", price_cents as "priceCents",
promo_price_cents as "promoPriceCents", promo_label as "promoLabel"
from groceries.item_prices where item_id = any(${dItems.map((i) => i.id)}::int[])
order by observed_on desc limit 400`
: [];
detail = { group, items: dItems, similar, history, manual: dManual };
}
}
return {
stores,
groups,
items,
manual,
total,
page,
pageSize: PAGE_SIZE,
filters: {
q: p.get('q') ?? '',
store: store?.slug ?? '',
category: cat?.slug ?? '',
sort,
favorites,
promo,
multi,
nonfood
},
categories,
manualFreshDays: MANUAL_FRESH_DAYS,
stats,
runs,
detail,
seo: { title: 'Groceries', description: 'Private supermarket price comparison.', index: false }
};
};
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export const actions: Actions = {
/**
* Record a shelf price seen in a store (owner only): for a store product or a whole group,
* with an optional pack size (for the unit price), date, note and photo.
*/
addManualPrice: async (event) => {
requireOwner(event);
const s = sql();
const form = await event.request.formData();
const storeId = id(form.get('storeId'));
let groupId = id(form.get('groupId'));
const itemId = id(form.get('itemId'));
const priceCents = parseEuros(String(form.get('price') ?? ''));
if (!storeId) return fail(400, { message: 'Pick a store' });
if (!priceCents) return fail(400, { message: 'Enter a price like 1,29' });
const [st] =
await s`select id from groceries.stores where id = ${storeId} and archived_at is null`;
if (!st) return fail(404, { message: 'Store not found' });
type ItemSize = { group_id: number | null; unit: string | null; unit_amount: number | null };
let item: ItemSize | undefined;
if (itemId) {
[item] = await s<ItemSize[]>`
select group_id, unit, unit_amount::float8 as unit_amount from groceries.items where id = ${itemId}`;
if (!item) return fail(404, { message: 'Product not found' });
groupId = item.group_id ?? groupId;
}
if (!groupId) return fail(400, { message: 'Pick a product' });
const [grp] = await s<{ id: number; unit: string | null }[]>`
select coalesce(merged_into_id, id) as id, unit from groceries.groups where id = ${groupId}`;
if (!grp) return fail(404, { message: 'Product not found' });
groupId = grp.id;
const sizeText =
String(form.get('size') ?? '')
.trim()
.slice(0, 60) || null;
let size = parseSize(sizeText);
if (!size && item?.unit && item.unit_amount)
size = { unit: item.unit as 'kg', amount: item.unit_amount };
if (!size && !sizeText) {
// Same pack as the group's usual one (when its products agree on a size).
const [usual] = await s<{ unit: string; amount: number }[]>`
select unit, unit_amount::float8 as amount from groceries.items
where group_id = ${groupId} and available and unit is not null and unit_amount > 0
group by 1, 2 order by count(*) desc limit 1`;
if (usual) size = { unit: usual.unit as 'kg', amount: usual.amount };
}
const unitPriceCents = size && size.amount > 0 ? Math.round(priceCents / size.amount) : null;
const dateRaw = String(form.get('observedOn') ?? '');
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
const observedOn =
ISO_DATE.test(dateRaw) && dateRaw >= '2020-01-01' && dateRaw <= today ? dateRaw : today;
const note =
String(form.get('note') ?? '')
.trim()
.slice(0, 500) || null;
let photoUrl: string | null = null;
const photo = form.get('photo');
if (photo instanceof File && photo.size > 0) {
if (!PHOTO_TYPES.includes(photo.type) || photo.size > 15 * 1024 * 1024)
return fail(400, { message: 'Photo: JPEG, PNG or WebP up to 15 MB' });
try {
photoUrl = (await uploadImage(photo, 'groceries')).publicUrl;
} catch (e) {
console.error('groceries photo upload failed', e);
return fail(502, { message: 'Photo upload failed; try again without the photo' });
}
}
const [row] = await s<{ id: number }[]>`
insert into groceries.manual_prices (store_id, group_id, item_id, price_cents, promo, size_text, unit,
unit_amount, unit_price_cents, observed_on, photo_url, note)
values (${storeId}, ${groupId}, ${itemId}, ${priceCents}, ${form.get('promo') === 'on' || form.get('promo') === '1'},
${sizeText}, ${size?.unit ?? null}, ${size?.amount ?? null}, ${unitPriceCents}, ${observedOn},
${photoUrl}, ${note})
returning id`;
return { manualAdded: row.id, groupId };
},
deleteManualPrice: async (event) => {
requireOwner(event);
const mid = id((await event.request.formData()).get('id'));
if (!mid) return fail(400, { message: 'Invalid price' });
const [row] = await sql()<{ photo_url: string | null }[]>`
delete from groceries.manual_prices where id = ${mid} returning photo_url`;
const key = row?.photo_url ? groceryImageKey(row.photo_url) : null;
if (key)
await deleteObject(key).catch((e) => console.error('groceries photo delete failed', e));
return { manualDeleted: mid };
},
favorite: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const gid = id(form.get('id'));
if (!gid) return fail(400, { message: 'Invalid group' });
await sql()`update groceries.groups set favorite = ${form.get('favorite') === 'true'} where id = ${gid}`;
return { updated: gid };
},
/** Rename a group; it becomes manual so the nightly regroup keeps the name. */
rename: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const gid = id(form.get('id'));
const name = String(form.get('name') ?? '')
.trim()
.slice(0, 200);
if (!gid || !name) return fail(400, { message: 'Enter a name' });
const notes =
String(form.get('notes') ?? '')
.trim()
.slice(0, 2000) || null;
await sql()`update groceries.groups set name = ${name}, notes = ${notes}, manual = true where id = ${gid}`;
await refreshGroupStats(sql(), [gid]);
return { updated: gid };
},
/** Merge group `id` into `into`: its items move (locked) and future matches follow. */
merge: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const from = id(form.get('id'));
const into = id(form.get('into'));
if (!from || !into || from === into) return fail(400, { message: 'Pick another group' });
const [target] = await sql()`
select id from groceries.groups where id = ${into} and merged_into_id is null`;
if (!target) return fail(404, { message: 'Group not found' });
await sql().begin(async (tx) => {
await tx`
update groceries.items set group_id = ${into}, match_locked = true, match_method = 'manual',
match_confidence = 1 where group_id = ${from}`;
await tx`update groceries.groups set merged_into_id = ${into} where merged_into_id = ${from}`;
const [src] = await tx<{ favorite: boolean }[]>`
select favorite from groceries.groups where id = ${from}`;
await tx`update groceries.groups set merged_into_id = ${into}, favorite = false where id = ${from}`;
if (src?.favorite) await tx`update groceries.groups set favorite = true where id = ${into}`;
await refreshGroupStats(tx as unknown as Sql, [from, into]);
});
return { merged: into };
},
/** Move one item to another group, or to a new group of its own (split). Locks the item. */
moveItem: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const itemId = id(form.get('itemId'));
const target = form.get('into') === 'new' ? 'new' : id(form.get('into'));
if (!itemId || !target) return fail(400, { message: 'Pick a group' });
const moved = await sql().begin(async (tx) => {
const [item] = await tx<
{
group_id: number | null;
name: string;
brand: string | null;
unit: string | null;
category: string | null;
}[]
>`
select group_id, name, brand, unit, category from groceries.items where id = ${itemId}`;
if (!item) return null;
let into: number;
if (target === 'new') {
const [g] = await tx<{ id: number }[]>`
insert into groceries.groups (name, brand, category, unit, manual)
values (${displayName(item.name, item.brand).slice(0, 200)}, ${item.brand}, ${item.category}, ${item.unit}, true)
returning id`;
into = g.id;
} else {
const [g] =
await tx`select id from groceries.groups where id = ${target} and merged_into_id is null`;
if (!g) return null;
into = target;
}
await tx`
update groceries.items set group_id = ${into}, match_locked = true, match_method = 'manual',
match_confidence = 1 where id = ${itemId}`;
await refreshGroupStats(tx as unknown as Sql, [
into,
...(item.group_id ? [item.group_id] : [])
]);
return into;
});
if (!moved) return fail(404, { message: 'Not found' });
return { moved };
},
/** Hand an item back to the automatic matcher (applies at the next nightly import). */
unlockItem: async (event) => {
requireOwner(event);
const itemId = id((await event.request.formData()).get('itemId'));
if (!itemId) return fail(400, { message: 'Invalid item' });
await sql()`update groceries.items set match_locked = false where id = ${itemId}`;
return { unlocked: itemId };
}
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { fold } from '#lib/server/groceries/normalize.js';
import {
expand,
queryWords,
rankedIds,
searchConds,
searchContext
} from '#lib/server/groceries/search.js';
import { json } from '@sveltejs/kit';
import type { Sql } from 'postgres';
import type { RequestHandler } from './$types';
/** Group picker for merge/move and shelf prices: top matches for ?q= (owner only). */
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const q = fold(event.url.searchParams.get('q')?.trim() ?? '').slice(0, 100);
const s = db.$client as unknown as Sql;
const words = expand(queryWords(q), (await searchContext(s)).synonyms);
if (!words.length) return json([]);
const where = [s`merged_into_id is null`, ...searchConds(s, words)].reduce(
(a, c) => s`${a} and ${c}`
);
const ids = (await rankedIds(s, where, q, words)).slice(0, 15);
const rows = ids.length
? await s<{ id: number }[]>`
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount",
image_url as "imageUrl", min_price_cents as "minPriceCents"
from groceries.groups g where id = any(${ids}::int[])`
: [];
const pos = new Map(ids.map((id, i) => [id, i]));
rows.sort((a, b) => pos.get(a.id)! - pos.get(b.id)!);
return json(rows, { headers: { 'cache-control': 'private, no-store' } });
};
@@ -0,0 +1,114 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { computeBattles } from '#lib/server/groceries/stats.js';
import type {
BattleRow,
H2HRow,
Overview,
PromoRow,
Section,
SeriesData,
SnapshotData,
StatsScope
} from '#lib/groceries-stats.js';
import type { Sql } from 'postgres';
import type { PageServerLoad } from './$types';
const sql = () => db.$client as unknown as Sql;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
type Live = { battles: BattleRow[]; h2h: H2HRow[]; total: number; tied: number };
// Store-subset battles are recomputed live (~1 s); cache per snapshot so filter clicks are instant.
const liveCache = new Map<string, Promise<Record<string, Live>>>();
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const q = event.url.searchParams;
const scope: StatsScope = q.get('nonfood') === '1' ? 'all' : 'food';
const cat = (q.get('cat') ?? '').trim() || 'all';
const from = DAY.test(q.get('from') ?? '') ? q.get('from')! : null;
const to = DAY.test(q.get('to') ?? '') ? q.get('to')! : null;
const stores = [
...new Set(
(q.get('stores') ?? '')
.split(',')
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0)
)
].sort((a, b) => a - b);
const s = sql();
const [snap] = await s`
select computed_on::text as day, duration_ms, data->'meta' as meta, data->'overview' as overview,
coalesce(data->'byCategory'->${cat}, case when ${cat} = 'all' then data->'overall' end) as section
from groceries.stats_snapshots
where scope = ${scope} and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on desc limit 1`;
const days = (
await s`select computed_on::text as day from groceries.stats_snapshots where scope = ${scope}
order by computed_on`
).map((r) => r.day as string);
const base = { scope, cat, from, to, stores, days };
if (!snap) return { ...base, empty: true as const };
// Daily index series: the newest snapshot that still carries it, cut to the category/range.
const [ser] = await s`
select series->'days' as days, series->'cats'->${cat} as cat
from groceries.stats_snapshots
where scope = ${scope} and series is not null and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on desc limit 1`;
let series: { days: string[]; rows: SeriesData['cats'][string] } | null = null;
if (ser?.days && ser.cat) {
const all = ser.days as string[];
const keep = all
.map((d, i) => [d, i] as const)
.filter(([d]) => (!from || d >= from) && (!to || d <= to));
const idx = keep.map(([, i]) => i);
const rows: SeriesData['cats'][string] = {};
for (const [k, v] of Object.entries(ser.cat as SeriesData['cats'][string]))
rows[k] = { r: idx.map((i) => v.r[i]), e: idx.map((i) => v.e[i]), c: idx.map((i) => v.c[i]) };
series = { days: keep.map(([d]) => d), rows };
}
// Nightly history of the battle and promo tables (older snapshots keep only the overall section).
const trend = (await s`
select computed_on::text as day,
coalesce(data->'byCategory'->${cat}->'battles', case when ${cat} = 'all' then data->'overall'->'battles' end) as battles,
coalesce(data->'byCategory'->${cat}->'promos', case when ${cat} = 'all' then data->'overall'->'promos' end) as promos
from groceries.stats_snapshots
where scope = ${scope} and (${from}::date is null or computed_on >= ${from}::date)
and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on`) as unknown as {
day: string;
battles: BattleRow[] | null;
promos: PromoRow[] | null;
}[];
const meta = snap.meta as SnapshotData['meta'];
let live: Live | null = null;
if (stores.length >= 2) {
const key = `${scope}|${snap.day}|${meta.computedAt}|${stores.join(',')}`;
let job = liveCache.get(key);
if (!job) {
if (liveCache.size > 50) liveCache.clear();
job = computeBattles(s, scope, stores);
liveCache.set(key, job);
job.catch(() => liveCache.delete(key));
}
const all = await job;
live = all[cat] ?? { battles: [], h2h: [], total: 0, tied: 0 };
}
return {
...base,
empty: false as const,
day: snap.day as string,
durationMs: snap.duration_ms as number | null,
meta,
overview: (snap.overview ?? null) as Overview | null,
section: (snap.section ?? null) as Section | null,
series,
trend,
live
};
};
File diff suppressed because it is too large. Load diff