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

+1 -1
View File
@@ -443,7 +443,7 @@ No public signup grants admin privileges.
# CS prices
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
`/trackers/prices/cs` (the CS skins tab of the price tracker; the old `/projects/cs-prices` URLs redirect) reads only from Postgres. Each Steam market hash name is an
individual listing. The separate `cs.item_dimensions` table makes it possible to
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
additional imported dimensions such as collection or rarity. Group chart points
+10 -1
View File
@@ -9,7 +9,8 @@ import { startNotesIndexer } from '#lib/server/notes/indexer.js';
import { building } from '$app/env';
import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
import { error } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import { legacyPriceRedirect } from '#lib/price-trackers.js';
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
@@ -47,6 +48,14 @@ export const handle: Handle = async ({ event, resolve }) => {
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
error(404, 'Not Found');
// Groceries and CS prices moved into the price tracker (/trackers/prices/<tab>): old URLs,
// sub-paths and API endpoints redirect with their query string (301 for reads, 308 for
// POSTs so the body and method survive). The old private URLs 404 for everyone else.
const moved = legacyPriceRedirect(event.url.pathname);
if (moved) {
if (moved.private && !owner) error(404, 'Not Found');
redirect(read ? 301 : 308, moved.to + event.url.search);
}
// Private pages (the /start new-tab dashboard, trackers) skip Google Analytics: less to
// load, and nothing private gets reported.
const response = await resolve(
+1 -1
View File
@@ -1,4 +1,4 @@
// Shared (client + server) types and helpers for /trackers/groceries/stats. The snapshot is
// Shared (client + server) types and helpers for /trackers/prices/groceries/stats. The snapshot is
// computed nightly by computeStats (src/lib/server/groceries/stats.ts); short keys keep it small.
export type StatsScope = 'food' | 'all';
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { defaultPriceTab, legacyPriceRedirect, visiblePriceTabs } from './price-trackers';
describe('price tracker', () => {
it('redirects old groceries and CS prices URLs with their sub-paths', () => {
expect(legacyPriceRedirect('/trackers/groceries')).toEqual({
to: '/trackers/prices/groceries',
private: true
});
expect(legacyPriceRedirect('/trackers/groceries/stats')?.to).toBe(
'/trackers/prices/groceries/stats'
);
expect(legacyPriceRedirect('/projects/cs-prices/ak-47-redline')).toEqual({
to: '/trackers/prices/cs/ak-47-redline',
private: false
});
expect(legacyPriceRedirect('/projects/cs-prices/api/series')?.to).toBe(
'/trackers/prices/cs/api/series'
);
expect(legacyPriceRedirect('/trackers/groceriesx')).toBeNull();
expect(legacyPriceRedirect('/trackers/prices/cs')).toBeNull();
});
it('hides private tabs from everyone but the owner', () => {
expect(visiblePriceTabs(false).map((t) => t.slug)).toEqual(['cs']);
expect(visiblePriceTabs(true).map((t) => t.slug)).toEqual(['groceries', 'cs']);
expect(defaultPriceTab(false).slug).toBe('cs');
expect(defaultPriceTab(true).slug).toBe('groceries');
});
});
+60
View File
@@ -0,0 +1,60 @@
// The price tracker (/trackers/prices): one page per thing whose prices I follow, shown as
// tabs. To add a tracker: put its route under src/routes/trackers/prices/<slug>, add a tab
// here (and a colourful icon in #lib/tracker-icons under `icon`), and add the slug to
// ownerOnlyPrefixes/ownerWritePrefixes in #lib/server/owner when it is private.
export type PriceTab = {
/** URL segment: /trackers/prices/<slug>. */
slug: string;
/** Tab label (short, lowercase like the rest of the nav). */
name: string;
/** Icon key in #lib/tracker-icons. */
icon: string;
subtitle: string;
href: string;
/** Owner-only: hidden from the tab bar for everyone else, and the route 404s for them. */
private: boolean;
};
export const priceTabs: readonly PriceTab[] = [
{
slug: 'groceries',
name: 'groceries',
icon: 'groceries',
subtitle: 'supermarket products, favorites and prices per store',
href: '/trackers/prices/groceries',
private: true
},
{
slug: 'cs',
name: 'cs skins',
icon: 'skins',
subtitle: 'Counter-Strike skin market prices across time',
href: '/trackers/prices/cs',
private: false
}
];
export const visiblePriceTabs = (isOwner: boolean) =>
priceTabs.filter((tab) => isOwner || !tab.private);
/** Where the bare /trackers/prices goes: the first tab the visitor can see. */
export const defaultPriceTab = (isOwner: boolean) => visiblePriceTabs(isOwner)[0];
/**
* Old URLs from before the merge (whole subtrees, including the stats page, item pages and
* API endpoints). hooks.server.ts redirects them; `private` ones 404 for everyone but me.
*/
export const legacyPriceRoutes = [
{ from: '/trackers/groceries', to: '/trackers/prices/groceries', private: true },
{ from: '/projects/cs-prices', to: '/trackers/prices/cs', private: false }
] as const;
/** New location for an old price-tracker path (keeps the rest of the path), or null. */
export function legacyPriceRedirect(pathname: string): { to: string; private: boolean } | null {
for (const route of legacyPriceRoutes) {
if (pathname === route.from || pathname.startsWith(`${route.from}/`))
return { to: route.to + pathname.slice(route.from.length), private: route.private };
}
return null;
}
+5 -11
View File
@@ -448,11 +448,12 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
private: true
},
{
name: 'groceries',
subtitle: 'supermarket products, favorites and prices per store',
href: '/trackers/groceries',
// One price tracker with a tab per thing (#lib/price-trackers): groceries (mine) and CS skins.
name: 'prices',
subtitle: 'prices across time: groceries per store and Counter-Strike skins',
href: '/trackers/prices',
external: false,
private: true
private: false
},
{
name: 'houses',
@@ -476,13 +477,6 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
children: gameTrackers,
private: false
},
{
name: 'skins',
subtitle: 'Counter-Strike skin market prices across time',
href: '/projects/cs-prices',
external: false,
private: false
},
{
name: 'pets',
subtitle: 'animaldex: add neighborhood pets to the world map',
+1 -1
View File
@@ -104,7 +104,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
title: 'Animaldex',
description: 'Find and add neighborhood animals on a map.'
},
'/projects/cs-prices': {
'/trackers/prices/cs': {
title: 'CS Prices',
description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
+1 -1
View File
@@ -279,7 +279,7 @@ export const grocerySynonyms = groceriesSchema.table(
);
/**
* Nightly analytics for /trackers/groceries/stats (computeStats in
* Nightly analytics for /trackers/prices/groceries/stats (computeStats in
* src/lib/server/groceries/stats.ts, run after import + regroup + taxonomy under the import lock).
* One row per day per scope ('food' = edible only, 'all' = incl. drugstore/household). `data` holds
* the overall section plus one per top category; `series` (daily chained price index) is only kept
+1 -1
View File
@@ -1,4 +1,4 @@
// Analytics for /trackers/groceries/stats. computeStats() runs nightly after the import, regroup
// Analytics for /trackers/prices/groceries/stats. computeStats() runs nightly after the import, regroup
// and taxonomy (scripts/groceries-import.ts, under the import lock) and stores one snapshot per
// scope in groceries.stats_snapshots; the page only reads snapshots. computeBattles() is the
// same battle maths for a store subset, used live when the page filters stores.
+4
View File
@@ -22,6 +22,10 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
expect(isOwnerOnlyPath('/trackers/prices/groceries/stats')).toBe(true);
expect(isOwnerWritePath('/trackers/prices/groceries')).toBe(true);
expect(isOwnerOnlyPath('/trackers/prices/cs')).toBe(false);
expect(isOwnerWritePath('/trackers/prices/cs/api/series')).toBe(false);
expect(isOwnerOnlyPath('/trackers/data-holders')).toBe(true);
expect(isOwnerWritePath('/trackers/data-holders')).toBe(true);
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
+3
View File
@@ -24,6 +24,8 @@ export const ownerOnlyPrefixes = [
'/start',
'/trackers/health',
'/trackers/tasks',
// Price tracker: the groceries tab is mine; the old URL 404s too instead of redirecting.
'/trackers/prices/groceries',
'/trackers/groceries',
'/trackers/houses',
'/trackers/data-holders',
@@ -60,6 +62,7 @@ export const ownerWritePrefixes = [
'/start',
'/trackers/health',
'/trackers/tasks',
'/trackers/prices/groceries',
'/trackers/groceries',
'/trackers/houses',
'/trackers/data-holders',
+3 -1
View File
@@ -36,7 +36,9 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/animaldex/register': 'Log a sighting',
'/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices',
'/trackers/prices': 'Prices',
'/trackers/prices/cs': 'CS Prices',
'/trackers/prices/groceries': 'Groceries',
'/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder',
+10
View File
@@ -189,6 +189,16 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M17.3 5H6.7a4 4 0 0 0-4 3.6C2.6 9.4 2 14.5 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.5-.6-6.6-.7-7.3A4 4 0 0 0 17.3 5z'
]
},
prices: {
from: '#22d3ee',
to: '#0e7490',
ink: WHITE,
// price tag
paths: [
'M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z',
c(7.5, 7.5, 1.2)
]
},
skins: {
from: '#e879f9',
to: '#a21caf',
+1
View File
@@ -32,6 +32,7 @@ const staticPaths = [
'/trackers/books',
'/trackers/ai',
'/trackers/radio',
'/trackers/prices/cs',
'/trackers/spotting',
'/trackers/cars',
'/trackers/cars/brands',
+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);
};
@@ -28,7 +28,7 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
params.set('offset', String(offset));
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
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[];
}
@@ -203,7 +203,7 @@
searchError = '';
try {
const response = await fetch(
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
`/trackers/prices/cs/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error(`Search failed (${response.status})`);
@@ -225,7 +225,7 @@
searchError = '';
try {
const response = await fetch(
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(dimension)}&q=${encodeURIComponent(query)}&limit=1000`
`/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[];
@@ -262,7 +262,7 @@
loading = true;
error = '';
try {
const response = await fetch('/projects/cs-prices/api/series', {
const response = await fetch('/trackers/prices/cs/api/series', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
@@ -357,7 +357,7 @@
{#each hits as hit (hit.slug)}
<a
class="hit"
href={`/projects/cs-prices/${hit.slug}`}
href={`/trackers/prices/cs/${hit.slug}`}
style:--rarity={hit.rarity_color ?? 'transparent'}
>
{#if hit.image}<img
@@ -164,7 +164,7 @@
</svelte:head>
<div class="cs-item">
<a class="back" href="/projects/cs-prices">← CS prices</a>
<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">
@@ -254,7 +254,7 @@
// ---- merge / move picker ------------------------------------------------------------------
async function findGroups(q: string): Promise<Hit[]> {
if (!q.trim()) return [];
const res = await fetch(`/trackers/groceries/search?q=${encodeURIComponent(q.trim())}`);
const res = await fetch(`/trackers/prices/groceries/search?q=${encodeURIComponent(q.trim())}`);
return res.ok ? ((await res.json()) as Hit[]) : [];
}
function openPicker(p: NonNullable<typeof picker>) {
@@ -487,7 +487,7 @@
<dd>{fmtNum(data.stats.promos)}</dd>
</div>
</dl>
<a href="/trackers/groceries/stats" class="stats-link">
<a href="/trackers/prices/groceries/stats" class="stats-link">
<ChartColumn size={16} aria-hidden="true" class="text-violet-500" /> stats
</a>
</header>
@@ -621,7 +621,7 @@
· page {data.page} of {fmtNum(pages)}{/if}</span
>
{#if data.filters.q || data.filters.category || data.filters.store || data.filters.favorites || data.filters.promo || data.filters.multi}
<a href="/trackers/groceries" class="clear" data-sveltekit-reset="false"
<a href="/trackers/prices/groceries" class="clear" data-sveltekit-reset="false"
><X size={12} />Clear</a
>
{/if}
@@ -588,7 +588,7 @@
<div class="wrap mx-auto max-w-6xl px-4 pt-10 pb-28 sm:px-6 sm:pt-14">
<header class="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<a href="/trackers/groceries" class="back"><ArrowLeft size={14} /> groceries</a>
<a href="/trackers/prices/groceries" class="back"><ArrowLeft size={14} /> groceries</a>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries stats</h1>
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" />