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
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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.'
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -32,6 +32,7 @@ const staticPaths = [
|
||||
'/trackers/books',
|
||||
'/trackers/ai',
|
||||
'/trackers/radio',
|
||||
'/trackers/prices/cs',
|
||||
'/trackers/spotting',
|
||||
'/trackers/cars',
|
||||
'/trackers/cars/brands',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
File renamed without changes.
+5
-5
@@ -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
|
||||
File renamed without changes.
+1
-1
@@ -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">
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-3
@@ -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}
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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" />
|
||||
Reference in new issue
Block a user