From 81b0c51672af35fc225ee49ef0a940801fc31458 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Tue, 27 Oct 2026 10:38:10 +0000 Subject: [PATCH] prices: fold groceries and CS skins into one price tracker (/trackers/prices) with tabs - 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 --- README.md | 2 +- src/hooks.server.ts | 11 +++- src/lib/groceries-stats.ts | 2 +- src/lib/price-trackers.test.ts | 29 +++++++++ src/lib/price-trackers.ts | 60 +++++++++++++++++++ src/lib/projects.ts | 16 ++--- src/lib/seo.ts | 2 +- src/lib/server/db/schema/groceries.ts | 2 +- src/lib/server/groceries/stats.ts | 2 +- src/lib/server/owner.test.ts | 4 ++ src/lib/server/owner.ts | 3 + src/lib/title.ts | 4 +- src/lib/tracker-icons.ts | 10 ++++ src/routes/sitemap.xml/+server.ts | 1 + src/routes/trackers/prices/+layout.svelte | 14 +++++ src/routes/trackers/prices/+page.server.ts | 9 +++ .../prices/cs}/+page.server.ts | 0 .../prices/cs}/+page.svelte | 10 ++-- .../prices/cs}/[slug]/+page.server.ts | 0 .../prices/cs}/[slug]/+page.svelte | 2 +- .../prices/cs}/api/search/+server.ts | 0 .../prices/cs}/api/series/+server.ts | 0 .../prices/cs}/api/skins/+server.ts | 0 .../{ => prices}/groceries/+layout.server.ts | 0 .../{ => prices}/groceries/+page.server.ts | 0 .../{ => prices}/groceries/+page.svelte | 6 +- .../{ => prices}/groceries/search/+server.ts | 0 .../groceries/stats/+page.server.ts | 0 .../{ => prices}/groceries/stats/+page.svelte | 2 +- 29 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 src/lib/price-trackers.test.ts create mode 100644 src/lib/price-trackers.ts create mode 100644 src/routes/trackers/prices/+layout.svelte create mode 100644 src/routes/trackers/prices/+page.server.ts rename src/routes/{projects/cs-prices => trackers/prices/cs}/+page.server.ts (100%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/+page.svelte (98%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/[slug]/+page.server.ts (100%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/[slug]/+page.svelte (99%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/api/search/+server.ts (100%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/api/series/+server.ts (100%) rename src/routes/{projects/cs-prices => trackers/prices/cs}/api/skins/+server.ts (100%) rename src/routes/trackers/{ => prices}/groceries/+layout.server.ts (100%) rename src/routes/trackers/{ => prices}/groceries/+page.server.ts (100%) rename src/routes/trackers/{ => prices}/groceries/+page.svelte (99%) rename src/routes/trackers/{ => prices}/groceries/search/+server.ts (100%) rename src/routes/trackers/{ => prices}/groceries/stats/+page.server.ts (100%) rename src/routes/trackers/{ => prices}/groceries/stats/+page.svelte (99%) diff --git a/README.md b/README.md index 4042265..d18693a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2609087..dd12a47 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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 = /[\s\S]*?/; @@ -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/): 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( diff --git a/src/lib/groceries-stats.ts b/src/lib/groceries-stats.ts index 76750e7..cdeb2df 100644 --- a/src/lib/groceries-stats.ts +++ b/src/lib/groceries-stats.ts @@ -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'; diff --git a/src/lib/price-trackers.test.ts b/src/lib/price-trackers.test.ts new file mode 100644 index 0000000..2d2c37c --- /dev/null +++ b/src/lib/price-trackers.test.ts @@ -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'); + }); +}); diff --git a/src/lib/price-trackers.ts b/src/lib/price-trackers.ts new file mode 100644 index 0000000..27efae6 --- /dev/null +++ b/src/lib/price-trackers.ts @@ -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/, 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: 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; +} diff --git a/src/lib/projects.ts b/src/lib/projects.ts index e57d29f..421f0b9 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -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', diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 665aba2..82e1da6 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -104,7 +104,7 @@ const publicPages: Record = { 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.' diff --git a/src/lib/server/db/schema/groceries.ts b/src/lib/server/db/schema/groceries.ts index ec6e1ae..cadd3ec 100644 --- a/src/lib/server/db/schema/groceries.ts +++ b/src/lib/server/db/schema/groceries.ts @@ -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 diff --git a/src/lib/server/groceries/stats.ts b/src/lib/server/groceries/stats.ts index 62fd5cc..01109f9 100644 --- a/src/lib/server/groceries/stats.ts +++ b/src/lib/server/groceries/stats.ts @@ -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. diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts index be8d6c5..5497c00 100644 --- a/src/lib/server/owner.test.ts +++ b/src/lib/server/owner.test.ts @@ -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); diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts index 5c29214..cf73b07 100644 --- a/src/lib/server/owner.ts +++ b/src/lib/server/owner.ts @@ -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', diff --git a/src/lib/title.ts b/src/lib/title.ts index 94e88c6..5d1e70f 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -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', diff --git a/src/lib/tracker-icons.ts b/src/lib/tracker-icons.ts index d31293c..5f4379b 100644 --- a/src/lib/tracker-icons.ts +++ b/src/lib/tracker-icons.ts @@ -189,6 +189,16 @@ export const trackerIcons: Record = { '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', diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index a052e0f..d29e8e5 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -32,6 +32,7 @@ const staticPaths = [ '/trackers/books', '/trackers/ai', '/trackers/radio', + '/trackers/prices/cs', '/trackers/spotting', '/trackers/cars', '/trackers/cars/brands', diff --git a/src/routes/trackers/prices/+layout.svelte b/src/routes/trackers/prices/+layout.svelte new file mode 100644 index 0000000..80778aa --- /dev/null +++ b/src/routes/trackers/prices/+layout.svelte @@ -0,0 +1,14 @@ + + + +{@render children()} diff --git a/src/routes/trackers/prices/+page.server.ts b/src/routes/trackers/prices/+page.server.ts new file mode 100644 index 0000000..1836b84 --- /dev/null +++ b/src/routes/trackers/prices/+page.server.ts @@ -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); +}; diff --git a/src/routes/projects/cs-prices/+page.server.ts b/src/routes/trackers/prices/cs/+page.server.ts similarity index 100% rename from src/routes/projects/cs-prices/+page.server.ts rename to src/routes/trackers/prices/cs/+page.server.ts diff --git a/src/routes/projects/cs-prices/+page.svelte b/src/routes/trackers/prices/cs/+page.svelte similarity index 98% rename from src/routes/projects/cs-prices/+page.svelte rename to src/routes/trackers/prices/cs/+page.svelte index f13c76e..b7fa7d8 100644 --- a/src/routes/projects/cs-prices/+page.svelte +++ b/src/routes/trackers/prices/cs/+page.svelte @@ -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)} {#if hit.image}
- ← CS prices + ← CS prices
{#if image}{/if}
diff --git a/src/routes/projects/cs-prices/api/search/+server.ts b/src/routes/trackers/prices/cs/api/search/+server.ts similarity index 100% rename from src/routes/projects/cs-prices/api/search/+server.ts rename to src/routes/trackers/prices/cs/api/search/+server.ts diff --git a/src/routes/projects/cs-prices/api/series/+server.ts b/src/routes/trackers/prices/cs/api/series/+server.ts similarity index 100% rename from src/routes/projects/cs-prices/api/series/+server.ts rename to src/routes/trackers/prices/cs/api/series/+server.ts diff --git a/src/routes/projects/cs-prices/api/skins/+server.ts b/src/routes/trackers/prices/cs/api/skins/+server.ts similarity index 100% rename from src/routes/projects/cs-prices/api/skins/+server.ts rename to src/routes/trackers/prices/cs/api/skins/+server.ts diff --git a/src/routes/trackers/groceries/+layout.server.ts b/src/routes/trackers/prices/groceries/+layout.server.ts similarity index 100% rename from src/routes/trackers/groceries/+layout.server.ts rename to src/routes/trackers/prices/groceries/+layout.server.ts diff --git a/src/routes/trackers/groceries/+page.server.ts b/src/routes/trackers/prices/groceries/+page.server.ts similarity index 100% rename from src/routes/trackers/groceries/+page.server.ts rename to src/routes/trackers/prices/groceries/+page.server.ts diff --git a/src/routes/trackers/groceries/+page.svelte b/src/routes/trackers/prices/groceries/+page.svelte similarity index 99% rename from src/routes/trackers/groceries/+page.svelte rename to src/routes/trackers/prices/groceries/+page.svelte index 8d74aa3..555124e 100644 --- a/src/routes/trackers/groceries/+page.svelte +++ b/src/routes/trackers/prices/groceries/+page.svelte @@ -254,7 +254,7 @@ // ---- merge / move picker ------------------------------------------------------------------ async function findGroups(q: string): Promise { 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) { @@ -487,7 +487,7 @@
{fmtNum(data.stats.promos)}
- +
@@ -621,7 +621,7 @@ · page {data.page} of {fmtNum(pages)}{/if} {#if data.filters.q || data.filters.category || data.filters.store || data.filters.favorites || data.filters.promo || data.filters.multi} - Clear {/if} diff --git a/src/routes/trackers/groceries/search/+server.ts b/src/routes/trackers/prices/groceries/search/+server.ts similarity index 100% rename from src/routes/trackers/groceries/search/+server.ts rename to src/routes/trackers/prices/groceries/search/+server.ts diff --git a/src/routes/trackers/groceries/stats/+page.server.ts b/src/routes/trackers/prices/groceries/stats/+page.server.ts similarity index 100% rename from src/routes/trackers/groceries/stats/+page.server.ts rename to src/routes/trackers/prices/groceries/stats/+page.server.ts diff --git a/src/routes/trackers/groceries/stats/+page.svelte b/src/routes/trackers/prices/groceries/stats/+page.svelte similarity index 99% rename from src/routes/trackers/groceries/stats/+page.svelte rename to src/routes/trackers/prices/groceries/stats/+page.svelte index 54b1960..7087490 100644 --- a/src/routes/trackers/groceries/stats/+page.svelte +++ b/src/routes/trackers/prices/groceries/stats/+page.svelte @@ -588,7 +588,7 @@
- groceries + groceries

groceries stats