prices: fold groceries and CS skins into one price tracker (/trackers/prices) with tabs
Deploy production / deploy (push) Successful in 1m30s
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:
1 parent
c175c25615
commit
81b0c51672
29 files changed
+163
-28
No files matched your search
@@ -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 }
|
||||
};
|
||||
};
|
||||
@@ -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
Reference in new issue
Block a user