6 Commits

Author SHA1 Message Date
9e89a615c6 cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly
- every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases
- cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163
- cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB)
- scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index
- skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts
- charts show points for short histories and pad sparse time axes
2026-10-26 08:47:57 +00:00
9d79dfb43a worldwide todo list: global map (List/Map toggle, clustered category pins, Global strip, pick on map, location_precision, tasks.geojson)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 08:38:51 +00:00
21b10ea894 Money forecast: name the first crash after the calm start in the explanation
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 08:29:28 +00:00
78b69c7d8f Money forecast: calm optimistic start, rising plateau, thin solid projected lines
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00
d3b9bb9ba2 money: tag interest/dividend payouts on bank sync and Revolut import (category interest|dividend, never overwrites a set category)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:48:47 +00:00
0121071af2 Tasks goals: investment income in the €10k/month bar (trailing-12m money.transactions amount_cents>0, category dividend|interest, any account except Joint, ÷12); own segment colour + breakdown line, breakdown amounts with cents
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:35:09 +00:00
47 changed files with 87387 additions and 162 deletions

View File

@@ -0,0 +1,32 @@
CREATE TYPE "worldtodo"."location_precision" AS ENUM('exact', 'approximate', 'none');--> statement-breakpoint
ALTER TABLE "worldtodo"."tasks" ADD COLUMN "location_precision" "worldtodo"."location_precision";--> statement-breakpoint
-- Hand-written: location_precision follows scale and lat/lon (map pins, tracker #4641).
-- street/city = exact, region/country = approximate (rough centre), planet or no coordinates
-- = none. A trigger instead of a generated column so drizzle-kit can diff the enum column.
CREATE OR REPLACE FUNCTION worldtodo.set_location_precision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.location_precision := CASE
WHEN NEW.lat IS NULL OR NEW.lon IS NULL OR NEW.scale = 'planet' THEN 'none'
WHEN NEW.scale IN ('street', 'city') THEN 'exact'
ELSE 'approximate'
END::worldtodo.location_precision;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS tasks_location_precision ON worldtodo.tasks;
--> statement-breakpoint
CREATE TRIGGER tasks_location_precision BEFORE INSERT OR UPDATE OF scale, lat, lon, location_precision
ON worldtodo.tasks FOR EACH ROW EXECUTE FUNCTION worldtodo.set_location_precision();
--> statement-breakpoint
-- Backfill (the trigger computes it) without bumping updated_at: touch_updated_at is paused
-- for this one statement when it exists.
DO $$
DECLARE has_touch boolean := EXISTS (
SELECT 1 FROM pg_trigger WHERE tgrelid = 'worldtodo.tasks'::regclass AND tgname = 'touch_updated_at'
);
BEGIN
IF has_touch THEN ALTER TABLE worldtodo.tasks DISABLE TRIGGER touch_updated_at; END IF;
UPDATE worldtodo.tasks SET location_precision = NULL;
IF has_touch THEN ALTER TABLE worldtodo.tasks ENABLE TRIGGER touch_updated_at; END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
CREATE TABLE "cs"."daily_prices" (
"item_id" integer,
"stream_id" smallint,
"day" date,
"price_cents" integer NOT NULL,
"volume" integer,
CONSTRAINT "daily_prices_pkey" PRIMARY KEY("item_id","stream_id","day")
);
--> statement-breakpoint
CREATE TABLE "cs"."fx_rates" (
"day" date PRIMARY KEY,
"usd_eur" numeric(12,8) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cs"."price_windows" (
"item_id" integer,
"source" text,
"currency" text NOT NULL,
"span" text,
"median_cents" integer,
"avg_cents" integer,
"min_cents" integer,
"max_cents" integer,
"volume" integer,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "price_windows_pkey" PRIMARY KEY("item_id","source","span")
);
--> statement-breakpoint
CREATE TABLE "cs"."skins" (
"id" text PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"weapon" text,
"pattern" text,
"category" text,
"rarity" text,
"rarity_color" text,
"image" text,
"min_float" real,
"max_float" real,
"collections" text[],
"source" text DEFAULT 'bymykel' NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP INDEX "cs"."items_hash_idx";--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "skin_id" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "phase" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "wear" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "stattrak" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "souvenir" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "id" smallint GENERATED BY DEFAULT AS IDENTITY (sequence name "cs"."price_streams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 32767 START WITH 1 CACHE 1);--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "label" text;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_fetched_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_rows" integer;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_error" text;--> statement-breakpoint
CREATE INDEX "daily_prices_day_idx" ON "cs"."daily_prices" ("day","stream_id");--> statement-breakpoint
CREATE UNIQUE INDEX "items_config_idx" ON "cs"."items" ("market_hash_name",coalesce("phase", ''));--> statement-breakpoint
CREATE INDEX "items_skin_idx" ON "cs"."items" ("skin_id");--> statement-breakpoint
CREATE UNIQUE INDEX "price_streams_id_idx" ON "cs"."price_streams" ("id");--> statement-breakpoint
CREATE UNIQUE INDEX "skins_slug_idx" ON "cs"."skins" ("slug");--> statement-breakpoint
CREATE INDEX "skins_name_idx" ON "cs"."skins" ("name");--> statement-breakpoint
CREATE INDEX "skins_kind_idx" ON "cs"."skins" ("kind");--> statement-breakpoint
ALTER TABLE "cs"."daily_prices" ADD CONSTRAINT "daily_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD CONSTRAINT "items_skin_id_skins_id_fkey" FOREIGN KEY ("skin_id") REFERENCES "cs"."skins"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "cs"."price_windows" ADD CONSTRAINT "price_windows_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
-- Facets for the listings that already exist (the daily job keeps these up to date).
UPDATE cs.items SET
stattrak = market_hash_name LIKE '%StatTrak™ %',
souvenir = market_hash_name LIKE 'Souvenir %',
wear = substring(market_hash_name from '\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$');
--> statement-breakpoint
-- Price streams the daily job writes (one row per config per stream per day).
INSERT INTO cs.price_streams (source, currency, kind, label) VALUES
('skinport', 'EUR', 'lowest-listing', 'Skinport lowest listing'),
('skinport', 'EUR', 'sale-median-24h', 'Skinport sales, 24h median'),
('steam-market', 'USD', 'sale-avg', 'Steam Market average sale (24h, else 7d)'),
('csfloat', 'USD', 'lowest-listing', 'CSFloat lowest listing'),
('buff163', 'USD', 'lowest-listing', 'Buff163 lowest listing')
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label;
--> statement-breakpoint
UPDATE cs.price_streams SET label = 'Steam Market lowest listing'
WHERE source = 'steam-market' AND currency = 'USD' AND kind = 'lowest-listing' AND label IS NULL;
--> statement-breakpoint
-- Backfill: the one Steam lowest-listing sweep from Sep 2026 becomes the first daily point.
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
SELECT DISTINCT ON (p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date)
p.item_id, s.id, (p.observed_at AT TIME ZONE 'UTC')::date, p.price_cents, p.volume
FROM cs.price_observations p
JOIN cs.price_streams s ON s.source = p.source AND s.currency = p.currency AND s.kind = p.kind
ORDER BY p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date, p.observed_at DESC
ON CONFLICT DO NOTHING;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON cs.skins, cs.daily_prices, cs.price_windows, cs.fx_rates TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cs TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"wildlife:refresh": "scripts/wildlife-refresh.sh",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"cs-prices:daily": "bun scripts/cs-prices.ts daily",
"house:worker": "bun scripts/house-worker.ts",
"house": "bun scripts/house-listing.ts",
"house:add": "bun scripts/house-listing.ts add",

624
scripts/cs-prices.ts Normal file
View File

@@ -0,0 +1,624 @@
/**
* CS2 catalog + daily prices (all configs) into cs.*. Runs inside the app container:
*
* bun scripts/cs-prices.ts daily catalog if older than 6 days, then today's prices
* bun scripts/cs-prices.ts catalog refresh the full catalog from ByMykel/CSGO-API
* bun scripts/cs-prices.ts prices today's prices from every bulk source
*
* One row per config per stream per UTC day (cs.daily_prices, idempotent upsert), so a rerun on
* the same day just refreshes that day. Bulk endpoints only, no per-item Steam scraping:
* - Skinport /v1/items (EUR, lowest listing + quantity, Doppler phases via `version`)
* - Skinport /v1/sales/history (EUR, 24h/7d/30d/90d sale aggregates)
* - prices.csgotrader.app (USD): Steam Market average sales (24h, else 7d), CSFloat and
* Buff163 lowest listings (with Doppler phases)
* USD→EUR comes from the ECB rate (Frankfurter) and is stored per day in cs.fx_rates.
*/
import postgres from 'postgres';
import { dimensionsFromName } from '../src/lib/cs-prices/taxonomy';
import { parseConfig, slugify } from '../src/lib/cs-prices/catalog';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
const mode = process.argv[2] ?? 'daily';
const day = new Date().toISOString().slice(0, 10);
const ua = { 'User-Agent': 'stanrunge.dev cs-prices/2.0 (daily personal price archive)' };
const bymykel = 'https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en';
type Skin = {
id: string;
slug: string;
name: string;
kind: string;
weapon: string | null;
pattern: string | null;
category: string | null;
rarity: string | null;
rarity_color: string | null;
image: string | null;
min_float: number | null;
max_float: number | null;
collections: string[] | null;
source: string;
};
type Item = {
market_hash_name: string;
phase: string | null;
skin_id: string;
name: string;
icon_url: string | null;
item_type: string | null;
wear: string | null;
stattrak: boolean;
souvenir: boolean;
};
async function getJson<T>(
url: string,
headers: Record<string, string> = {},
tries = 4
): Promise<T> {
let last: unknown;
for (let attempt = 0; attempt < tries; attempt++) {
try {
const response = await fetch(url, {
headers: { ...ua, ...headers },
signal: AbortSignal.timeout(180_000)
});
if (response.ok) return (await response.json()) as T;
last = new Error(`HTTP ${response.status} for ${new URL(url).host}${new URL(url).pathname}`);
if (response.status !== 429 && response.status < 500) break;
} catch (cause) {
last = cause;
}
await new Promise((resolve) => setTimeout(resolve, 5000 * 2 ** attempt));
}
throw last;
}
const configKey = (hash: string, phase: string | null) => `${hash}\u0000${phase ?? ''}`;
const cents = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 21_000_000
? Math.round(value * 100)
: null;
function kindFor(file: string, category?: string): string {
if (file === 'skins_not_grouped') {
if (category === 'Knives') return 'knife';
if (category === 'Gloves') return 'gloves';
return 'weapon';
}
return (
{
stickers: 'sticker',
crates: 'container',
agents: 'agent',
keychains: 'charm',
collectibles: 'collectible',
graffiti: 'graffiti',
patches: 'patch',
music_kits: 'music-kit',
keys: 'key'
}[file] ?? 'other'
);
}
/** Feed-only names (e.g. sticker slabs, highlight charms) still get a skin page. */
function kindFromName(hash: string) {
const { base, starred } = parseConfig(hash);
const head = base.split(' | ')[0];
if (starred) return /Gloves|Wraps/.test(head) ? 'gloves' : 'knife';
if (/^Sticker Slab$|^Charm$|Highlight/.test(head) || /^Souvenir Charm/.test(hash)) return 'charm';
if (/^Sticker$/.test(head)) return 'sticker';
if (/^(Sealed )?Graffiti$/.test(head)) return 'graffiti';
if (/^Patch$/.test(head)) return 'patch';
if (/^Music Kit$/.test(head)) return 'music-kit';
if (/Case|Capsule|Package|Container|Collection/.test(base)) return 'container';
if (dimensionsFromName(hash).category === 'weapon') return 'weapon';
return 'other';
}
async function upsertSkins(skins: Skin[]) {
for (let offset = 0; offset < skins.length; offset += 2000) {
const chunk = skins.slice(offset, offset + 2000);
await sql`
INSERT INTO cs.skins (id, slug, name, kind, weapon, pattern, category, rarity, rarity_color,
image, min_float, max_float, collections, source, updated_at)
SELECT id, slug, name, kind, weapon, pattern, category, rarity, rarity_color, image,
min_float, max_float, collections, source, now()
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(id text, slug text, name text,
kind text, weapon text, pattern text, category text, rarity text, rarity_color text,
image text, min_float real, max_float real, collections text[], source text)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, kind = EXCLUDED.kind,
weapon = coalesce(EXCLUDED.weapon, cs.skins.weapon),
pattern = coalesce(EXCLUDED.pattern, cs.skins.pattern),
category = coalesce(EXCLUDED.category, cs.skins.category),
rarity = coalesce(EXCLUDED.rarity, cs.skins.rarity),
rarity_color = coalesce(EXCLUDED.rarity_color, cs.skins.rarity_color),
image = coalesce(EXCLUDED.image, cs.skins.image),
min_float = coalesce(EXCLUDED.min_float, cs.skins.min_float),
max_float = coalesce(EXCLUDED.max_float, cs.skins.max_float),
collections = coalesce(EXCLUDED.collections, cs.skins.collections),
source = CASE WHEN EXCLUDED.source = 'bymykel' THEN 'bymykel' ELSE cs.skins.source END,
updated_at = CASE WHEN EXCLUDED.source = 'bymykel' THEN now() ELSE cs.skins.updated_at END`;
}
}
async function upsertItems(items: Item[]) {
const ids = new Map<string, number>();
for (let offset = 0; offset < items.length; offset += 2000) {
const chunk = items.slice(offset, offset + 2000);
const rows = await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
INSERT INTO cs.items (market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
stattrak, souvenir)
SELECT market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
coalesce(stattrak, false), coalesce(souvenir, false)
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(market_hash_name text,
phase text, skin_id text, name text, icon_url text, item_type text, wear text,
stattrak boolean, souvenir boolean)
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
skin_id = EXCLUDED.skin_id,
name = EXCLUDED.name,
icon_url = coalesce(EXCLUDED.icon_url, cs.items.icon_url),
item_type = coalesce(EXCLUDED.item_type, cs.items.item_type),
wear = EXCLUDED.wear, stattrak = EXCLUDED.stattrak, souvenir = EXCLUDED.souvenir,
last_seen_at = now()
RETURNING id, market_hash_name, phase`;
for (const row of rows) ids.set(configKey(row.market_hash_name, row.phase), row.id);
// Facets for the /projects/cs-prices compare explorer.
const dims: { item_id: number; key: string; value: string }[] = [];
const byKey = new Map(
chunk.map((item) => [configKey(item.market_hash_name, item.phase), item])
);
for (const row of rows) {
const item = byKey.get(configKey(row.market_hash_name, row.phase));
const facets = dimensionsFromName(row.market_hash_name);
if (row.phase) facets.phase = row.phase;
if (item?.item_type) facets.steam_type = item.item_type;
for (const [key, value] of Object.entries(facets))
if (value) dims.push({ item_id: row.id, key, value });
}
if (dims.length)
await sql`
INSERT INTO cs.item_dimensions (item_id, key, value)
SELECT item_id, key, value FROM jsonb_to_recordset(${sql.json(dims as never)}::jsonb)
AS x(item_id int, key text, value text)
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
}
return ids;
}
type Raw = Record<string, unknown> & {
id: string;
name: string;
market_hash_name?: string | null;
image?: string | null;
phase?: string | null;
skin_id?: string;
weapon?: { name?: string } | null;
pattern?: { name?: string } | null;
category?: { name?: string } | null;
rarity?: { name?: string; color?: string } | null;
min_float?: number | null;
max_float?: number | null;
collections?: { name?: string }[] | null;
type?: string | null;
};
async function catalog() {
const files = [
'skins_not_grouped',
'stickers',
'crates',
'agents',
'keychains',
'collectibles',
'graffiti',
'patches',
'music_kits',
'keys'
];
const skins = new Map<string, Skin>();
const items = new Map<string, Item>();
const slugOwner = new Map<string, string>();
const existing = await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`;
for (const row of existing) slugOwner.set(row.slug, row.id);
const skinFor = (base: string) => {
// The grouping key is the base name; the id stays stable once assigned.
const slug = slugify(base);
return slugOwner.get(slug) ?? slug;
};
for (const file of files) {
const rows = await getJson<Raw[]>(`${bymykel}/${file}.json`);
console.log(`ByMykel ${file}: ${rows.length}`);
for (const raw of rows) {
if (!raw.market_hash_name) continue;
const config = parseConfig(raw.market_hash_name);
const kind = kindFor(file, raw.category?.name);
const id = skinFor(config.base);
slugOwner.set(slugify(config.base), id);
const current = skins.get(id);
const preferImage = !raw.phase || raw.phase === 'Phase 2';
if (!current)
skins.set(id, {
id,
slug: slugify(config.base),
name: config.display,
kind,
weapon: raw.weapon?.name ?? null,
pattern: raw.pattern?.name ?? null,
category: raw.category?.name ?? raw.type ?? null,
rarity: raw.rarity?.name ?? null,
rarity_color: raw.rarity?.color ?? null,
image: preferImage ? (raw.image ?? null) : null,
min_float: raw.min_float ?? null,
max_float: raw.max_float ?? null,
collections: raw.collections?.map((c) => c.name ?? '').filter(Boolean) ?? null,
source: 'bymykel'
});
else if (!current.image && preferImage && raw.image) current.image = raw.image;
const add = (phase: string | null) =>
items.set(configKey(raw.market_hash_name!, phase), {
market_hash_name: raw.market_hash_name!,
phase,
skin_id: id,
name: phase ? `${raw.market_hash_name} - ${phase}` : raw.market_hash_name!,
icon_url: raw.image ?? null,
item_type: raw.category?.name ?? raw.type ?? kind,
wear: config.wear,
stattrak: config.stattrak,
souvenir: config.souvenir
});
add(raw.phase ?? null);
// The phase-less listing carries Steam's and other phase-blind prices.
if (raw.phase && !items.has(configKey(raw.market_hash_name, null))) add(null);
}
}
const itemList = [...items.values()];
for (const skin of skins.values())
if (!skin.image)
skin.image =
itemList.find((item) => item.skin_id === skin.id && item.icon_url)?.icon_url ?? null;
// Phase-less rows of Doppler listings should not show one phase's image.
for (const item of items.values())
if (!item.phase && /Doppler/.test(item.name)) item.icon_url = null;
await upsertSkins([...skins.values()]);
const ids = await upsertItems([...items.values()]);
// Older Steam-only rows: link them to a skin by name.
await sql`UPDATE cs.items i SET skin_id = s.id FROM cs.skins s
WHERE i.skin_id IS NULL AND s.slug = regexp_replace(regexp_replace(lower(regexp_replace(
regexp_replace(regexp_replace(i.market_hash_name, '^★ ', ''), '^(StatTrak™ |Souvenir )', ''),
' \\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\\)$', '')),
'[^a-z0-9]+', '-', 'g'), '^-+|-+$', '', 'g')`;
console.log(`Catalog: ${skins.size} skins, ${ids.size} configs`);
}
type Row = { hash: string; phase: string | null; cents: number; volume: number | null };
async function ensureItems(rows: Row[]) {
const known = new Map<string, number>();
for (const row of await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
SELECT id, market_hash_name, phase FROM cs.items`)
known.set(configKey(row.market_hash_name, row.phase), row.id);
const missing = new Map<string, Item>();
const newSkins = new Map<string, Skin>();
const slugs = new Map(
(await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`).map((r) => [
r.slug,
r.id
])
);
for (const row of rows) {
const key = configKey(row.hash, row.phase);
if (known.has(key) || missing.has(key)) continue;
// Untranslated tokens and truncated names in the feeds are not real listings.
if (/^#|\|\s*$|^\s*$/.test(row.hash) || row.hash.length > 200) continue;
const config = parseConfig(row.hash);
const slug = slugify(config.base);
let skinId = slugs.get(slug);
if (!skinId) {
skinId = slug;
slugs.set(slug, skinId);
newSkins.set(skinId, {
id: skinId,
slug,
name: config.display,
kind: kindFromName(row.hash),
weapon: null,
pattern: null,
category: null,
rarity: null,
rarity_color: null,
image: null,
min_float: null,
max_float: null,
collections: null,
source: 'market'
});
}
missing.set(key, {
market_hash_name: row.hash,
phase: row.phase,
skin_id: skinId,
name: row.phase ? `${row.hash} - ${row.phase}` : row.hash,
icon_url: null,
item_type: null,
wear: config.wear,
stattrak: config.stattrak,
souvenir: config.souvenir
});
}
if (newSkins.size) await upsertSkins([...newSkins.values()]);
if (missing.size) {
for (const [key, id] of await upsertItems([...missing.values()])) known.set(key, id);
console.log(`New configs from price feeds: ${missing.size} (${newSkins.size} new skins)`);
}
return known;
}
async function streamId(source: string, currency: string, kind: string) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs.price_streams (source, currency, kind) VALUES (${source}, ${currency}, ${kind})
ON CONFLICT (source, currency, kind) DO UPDATE SET source = EXCLUDED.source RETURNING id`;
return row.id;
}
async function saveDaily(source: string, currency: string, kind: string, rows: Row[]) {
const stream = await streamId(source, currency, kind);
const ids = await ensureItems(rows);
const dedup = new Map<number, Row>();
for (const row of rows) {
const id = ids.get(configKey(row.hash, row.phase));
if (id) dedup.set(id, row);
}
const entries = [...dedup.entries()];
for (let offset = 0; offset < entries.length; offset += 5000) {
const chunk = entries.slice(offset, offset + 5000);
await sql`
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
SELECT item_id, ${stream}::smallint, ${day}::date, price_cents, volume
FROM unnest(${chunk.map(([id]) => id)}::int[], ${chunk.map(([, r]) => r.cents)}::int[],
${chunk.map(([, r]) => r.volume)}::int[]) AS x(item_id, price_cents, volume)
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${entries.length},
last_error = NULL WHERE id = ${stream}`;
console.log(`${source} ${kind}: ${entries.length} configs for ${day}`);
return ids;
}
type Window = {
item: number;
span: string;
median: number | null;
avg: number | null;
min: number | null;
max: number | null;
volume: number | null;
};
async function saveWindows(source: string, currency: string, windows: Window[]) {
for (let offset = 0; offset < windows.length; offset += 5000) {
const chunk = windows.slice(offset, offset + 5000);
await sql`
INSERT INTO cs.price_windows (item_id, source, currency, span, median_cents, avg_cents,
min_cents, max_cents, volume, updated_at)
SELECT item_id, ${source}, ${currency}, span, median_cents, avg_cents, min_cents, max_cents,
volume, now()
FROM unnest(${chunk.map((w) => w.item)}::int[], ${chunk.map((w) => w.span)}::text[],
${chunk.map((w) => w.median)}::int[], ${chunk.map((w) => w.avg)}::int[],
${chunk.map((w) => w.min)}::int[], ${chunk.map((w) => w.max)}::int[],
${chunk.map((w) => w.volume)}::int[])
AS x(item_id, span, median_cents, avg_cents, min_cents, max_cents, volume)
ON CONFLICT (item_id, source, span) DO UPDATE SET median_cents = EXCLUDED.median_cents,
avg_cents = EXCLUDED.avg_cents, min_cents = EXCLUDED.min_cents,
max_cents = EXCLUDED.max_cents, volume = EXCLUDED.volume, updated_at = now()`;
}
}
async function fx() {
for (const url of [
'https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR',
'https://api.frankfurter.app/latest?from=USD&to=EUR'
]) {
try {
const data = await getJson<{ rates?: { EUR?: number } }>(url, {}, 2);
if (data.rates?.EUR) {
await sql`INSERT INTO cs.fx_rates (day, usd_eur) VALUES (${day}, ${data.rates.EUR})
ON CONFLICT (day) DO UPDATE SET usd_eur = EXCLUDED.usd_eur`;
console.log(`FX USD→EUR ${data.rates.EUR}`);
return;
}
} catch (cause) {
console.warn(`FX ${new URL(url).host}: ${String(cause)}`);
}
}
}
async function guarded(
source: string,
currency: string,
kinds: string[],
run: () => Promise<void>
) {
try {
await run();
return true;
} catch (cause) {
console.error(`${source} failed: ${String(cause)}`);
for (const kind of kinds)
await sql`UPDATE cs.price_streams SET last_error = ${String(cause).slice(0, 500)}
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}`;
return false;
}
}
const spans = { last_24_hours: '24h', last_7_days: '7d', last_30_days: '30d', last_90_days: '90d' };
async function prices() {
await fx();
const results: boolean[] = [];
const br = { 'Accept-Encoding': 'br' };
results.push(
await guarded('skinport', 'EUR', ['lowest-listing'], async () => {
const items = await getJson<
{
market_hash_name: string;
version: string | null;
min_price: number | null;
quantity: number;
}[]
>('https://api.skinport.com/v1/items?app_id=730&currency=EUR', br);
await saveDaily(
'skinport',
'EUR',
'lowest-listing',
items.flatMap((item) => {
const value = cents(item.min_price);
return value == null
? []
: [
{
hash: item.market_hash_name,
phase: item.version || null,
cents: value,
volume: item.quantity ?? null
}
];
})
);
})
);
results.push(
await guarded('skinport', 'EUR', ['sale-median-24h'], async () => {
type Agg = {
min: number | null;
max: number | null;
avg: number | null;
median: number | null;
volume: number;
};
const history = await getJson<
(Record<string, Agg> & { market_hash_name: string; version: string | null })[]
>('https://api.skinport.com/v1/sales/history?app_id=730&currency=EUR', br);
const rows: Row[] = [];
for (const entry of history) {
const day24 = entry.last_24_hours;
const value = cents(day24?.median);
if (value != null)
rows.push({
hash: entry.market_hash_name,
phase: entry.version || null,
cents: value,
volume: day24.volume
});
}
const ids = await saveDaily('skinport', 'EUR', 'sale-median-24h', rows);
const windows: Window[] = [];
const allIds = await ensureItems(
history.map((e) => ({
hash: e.market_hash_name,
phase: e.version || null,
cents: 0,
volume: 0
}))
);
for (const entry of history) {
const item = (ids.get(configKey(entry.market_hash_name, entry.version || null)) ??
allIds.get(configKey(entry.market_hash_name, entry.version || null)))!;
if (!item) continue;
for (const [field, span] of Object.entries(spans)) {
const agg = entry[field];
if (!agg || !agg.volume) continue;
windows.push({
item,
span,
median: cents(agg.median),
avg: cents(agg.avg),
min: cents(agg.min),
max: cents(agg.max),
volume: agg.volume
});
}
}
await saveWindows('skinport', 'EUR', windows);
console.log(`skinport windows: ${windows.length}`);
})
);
results.push(
await guarded('steam-market', 'USD', ['sale-avg'], async () => {
const steam = await getJson<Record<string, Record<string, number | null>>>(
'https://prices.csgotrader.app/latest/steam.json'
);
const rows: Row[] = [];
for (const [hash, value] of Object.entries(steam)) {
// Latest average sale price: the last 24 hours, else the last 7 days.
const price = cents(value?.last_24h ?? value?.last_7d);
if (price != null) rows.push({ hash, phase: null, cents: price, volume: null });
}
const ids = await saveDaily('steam-market', 'USD', 'sale-avg', rows);
const all = await ensureItems(
Object.keys(steam).map((hash) => ({ hash, phase: null, cents: 0, volume: 0 }))
);
const windows: Window[] = [];
for (const [hash, value] of Object.entries(steam)) {
const item = ids.get(configKey(hash, null)) ?? all.get(configKey(hash, null));
if (!item || !value) continue;
for (const span of ['24h', '7d', '30d', '90d']) {
const avg = cents(value[`last_${span}`]);
if (avg != null)
windows.push({ item, span, median: null, avg, min: null, max: null, volume: null });
}
}
await saveWindows('steam-market', 'USD', windows);
})
);
for (const [source, file] of [
['csfloat', 'csfloat'],
['buff163', 'buff163']
] as const) {
results.push(
await guarded(source, 'USD', ['lowest-listing'], async () => {
type Entry = {
price?: number;
starting_at?: { price?: number | null; doppler?: Record<string, number> } | null;
doppler?: Record<string, number>;
};
const feed = await getJson<Record<string, Entry>>(
`https://prices.csgotrader.app/latest/${file}.json`
);
const rows: Row[] = [];
for (const [hash, entry] of Object.entries(feed)) {
const base = cents(entry?.starting_at?.price ?? entry?.price);
if (base != null) rows.push({ hash, phase: null, cents: base, volume: null });
const phases = entry?.starting_at?.doppler ?? entry?.doppler;
for (const [phase, value] of Object.entries(phases ?? {})) {
const price = cents(value);
if (price != null) rows.push({ hash, phase, cents: price, volume: null });
}
}
await saveDaily(source, 'USD', 'lowest-listing', rows);
})
);
}
if (!results.some(Boolean)) throw new Error('Every price source failed');
const [summary] = await sql<{ rows: number; items: number }[]>`
SELECT count(*)::int AS rows, count(DISTINCT item_id)::int AS items
FROM cs.daily_prices WHERE day = ${day}`;
console.log(`Today (${day}): ${summary.rows} price rows across ${summary.items} configs`);
}
try {
if (mode === 'catalog') await catalog();
else if (mode === 'prices') await prices();
else if (mode === 'daily') {
const [state] = await sql<{ fresh: boolean }[]>`
SELECT coalesce(max(updated_at) > now() - interval '6 days', false) AS fresh
FROM cs.skins WHERE source = 'bymykel'`;
if (!state.fresh) await catalog();
await prices();
} else throw new Error('Usage: cs-prices.ts daily|catalog|prices');
await sql`ANALYZE cs.items`;
await sql`ANALYZE cs.daily_prices`;
} finally {
await sql.end();
}

View File

@@ -1,5 +1,6 @@
/**
* Steam Community Market ingestion. Run after db:migrate:cs-prices.
* Legacy Steam Community Market ingestion (per item, rate limited). The daily archive is
* scripts/cs-prices.ts; this stays for one-off Steam history imports with a logged-in session.
*
* bun scripts/ingest-cs-prices.ts catalog [--limit=500] [--start=0]
* STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... bun scripts/ingest-cs-prices.ts history [--limit=100] [--start=0]
@@ -87,10 +88,10 @@ async function itemId(
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
name = EXCLUDED.name,
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
icon_url = COALESCE(EXCLUDED.icon_url, cs.items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs.items.item_type),
last_seen_at = now()
RETURNING id`;
const inferred = dimensionsFromName(name);
@@ -281,7 +282,7 @@ try {
else if (mode === 'history') await history();
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
else throw new Error('Usage: ingest-cs-prices.ts catalog|history|import <file>');
await sql`ANALYZE cs_market_items, cs_item_dimensions, cs_price_observations`;
await sql`ANALYZE cs.items, cs.item_dimensions, cs.price_observations`;
} finally {
await sql.end();
}

View File

@@ -15,6 +15,8 @@
// category 'transfer' (e.g. Personal <-> Joint legs)
// --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the
// existing tracker rows those statement rows matched
// Interest/dividend payouts (src/lib/server/investment-income.ts) get category 'interest' /
// 'dividend' on insert; matched existing rows get it only when their category is empty.
// --apply write (needs a money-schema backup in ~/backups from the last 2 hours)
// --drop-start-balance with --apply: delete the account's "Start balance" row(s), only if the
// statement opens at €0 and, after the import, the only difference
@@ -38,6 +40,7 @@ import {
planImport,
rowsFromTable,
selectRows,
statementCategory,
statementExternalId,
type StatementRow,
type TrackerRow
@@ -293,12 +296,23 @@ try {
insert into money.transactions
(user_id, account_id, date, value_date, description, amount_cents, external_id, source, booking_status, category, raw)
values (${account.user_id}, ${account.id}, ${r.date}, ${r.startedDate}, ${r.description.slice(0, 500)},
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : null},
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : statementCategory(r)},
${q.json({ revolut: { type: r.type, product: r.product, started: r.startedDate, completed: r.completedAt, description: r.description, fee_cents: r.feeCents, currency: r.currency, balance_cents: r.balanceCents, file_line: r.line } })})
on conflict do nothing`;
inserted += res.count;
}
console.log(`Inserted ${inserted} rows into ${account.name}.`);
// Interest/dividend payouts that matched an existing row: tag it, unless it already
// has a category (hand-set categories are never overwritten).
let income = 0;
for (const d of plan.duplicates) {
const category = isTransfer(d.row) ? null : statementCategory(d.row);
if (!category) continue;
const res = await q`update money.transactions set category = ${category}
where id = ${d.match.id} and account_id = ${account.id} and category is null`;
income += res.count;
}
if (income) console.log(`Tagged ${income} existing interest/dividend rows.`);
if (transferRe && flag('--tag-matched-transfers')) {
const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id);
const tagged = ids.length

View File

@@ -2,13 +2,30 @@
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type PriceSeries = { key: string; name: string; points: [number, number][] };
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
$props();
export type PriceSeries = {
key: string;
name: string;
points: [number, number][];
color?: string;
dashed?: boolean;
};
let {
series,
kind,
currency,
height = 'min(62vh, 560px)',
zoom = true
}: {
series: PriceSeries[];
kind: string;
currency: string;
height?: string;
zoom?: boolean;
} = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
@@ -22,15 +39,53 @@
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
const many = series.length > 100;
const legend = series.length > 1 && series.length <= 12;
const surface = style.getPropertyValue('--site-surface').trim();
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
let first = Infinity;
let last = -Infinity;
for (const line of series)
for (const [t] of line.points) {
if (t < first) first = t;
if (t > last) last = t;
}
const day = 86_400_000;
const short = Number.isFinite(first) && last - first < 7 * day;
const pad = short ? (7 * day - (last - first)) / 2 : 0;
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 78, right: 20, top: 28, bottom: 72 },
grid: {
left: 64,
right: 14,
top: legend ? 40 : 20,
bottom: zoom ? 64 : 28,
containLabel: false
},
legend: legend
? {
top: 0,
left: 0,
icon: 'roundRect',
itemWidth: 10,
itemHeight: 4,
textStyle: { color: muted, fontSize: 11 }
}
: { show: false },
xAxis: {
type: 'time',
min: short ? first - pad : undefined,
max: short ? last + pad : undefined,
minInterval: day,
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
axisLabel: {
color: muted,
hideOverlap: true,
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
},
splitLine: { show: false }
},
yAxis: {
@@ -39,44 +94,71 @@
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
splitLine: { lineStyle: { color: border } }
},
dataZoom: [
dataZoom: zoom
? [
{ type: 'inside' },
{
type: 'slider',
bottom: 12,
bottom: 10,
height: 18,
borderColor: border,
textStyle: { color: muted },
fillerColor: 'rgba(127,127,127,.15)'
}
],
tooltip:
series.length > 100
]
: [],
tooltip: many
? { show: false }
: legend || series.length === 1
? {
trigger: 'axis',
backgroundColor: surface,
borderColor: border,
textStyle: { color: foreground, fontSize: 12 },
formatter: (
params: {
seriesName: string;
value: [number, number];
color: string;
}[]
) =>
params.length
? `${new Date(params[0].value[0]).toLocaleDateString('nl-NL')}<br>` +
params
.map(
(p) =>
`<span style="color:${p.color}">●</span> ${escapeHtml(p.seriesName)} · <b>${money.format(p.value[1] / 100)}</b>`
)
.join('<br>')
: ''
}
: {
trigger: 'item',
backgroundColor: style.getPropertyValue('--site-surface').trim(),
backgroundColor: surface,
borderColor: border,
textStyle: { color: foreground },
formatter: (params: { seriesName: string; value: [number, number] }) =>
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString('nl-NL')} · ${money.format(params.value[1] / 100)}`
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
data: item.points,
showSymbol: false,
// Few points (a new archive, or one sweep) would be invisible as a bare line.
showSymbol: item.points.length <= 60,
symbolSize: item.points.length <= 3 ? 7 : 4,
connectNulls: false,
silent: series.length > 100,
silent: many,
lineStyle: {
width: series.length > 100 ? 0.7 : 1.8,
opacity: series.length > 100 ? 0.42 : 0.9
width: many ? 0.7 : 1.8,
opacity: many ? 0.42 : 0.9,
type: item.dashed ? 'dashed' : 'solid'
},
itemStyle: {
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
color: item.color ?? `hsl(${(index * 137.508) % 360} 65% ${dark ? '68%' : '38%'})`
},
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
large: true
large: many
}))
},
true
@@ -90,7 +172,7 @@
import('echarts/core').then(async (echarts) => {
const [
{ LineChart },
{ GridComponent, TooltipComponent, DataZoomComponent },
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
@@ -98,7 +180,14 @@
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
DataZoomComponent,
LegendComponent,
CanvasRenderer
]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
@@ -107,7 +196,7 @@
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
attributeFilter: ['data-theme', 'class']
});
});
return () => {
@@ -118,12 +207,16 @@
};
});
$effect(() => {
// Track the props so a new selection redraws.
void series;
void currency;
if (ready) update();
});
</script>
<div
class="chart"
style:height
bind:this={element}
role="img"
aria-label={`${series.length} ${kind} price series chart`}
@@ -132,7 +225,6 @@
<style>
.chart {
width: 100%;
height: min(62vh, 560px);
min-height: 320px;
min-height: 260px;
}
</style>

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { configRank, parseConfig, slugify } from './catalog';
describe('parseConfig', () => {
it('splits knives, StatTrak and wear', () => {
expect(parseConfig('★ StatTrak™ Karambit | Doppler (Factory New)')).toEqual({
starred: true,
stattrak: true,
souvenir: false,
wear: 'Factory New',
base: 'Karambit | Doppler',
display: '★ Karambit | Doppler'
});
});
it('handles souvenirs and wearless items', () => {
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').souvenir).toBe(true);
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').base).toBe('AWP | Dragon Lore');
expect(parseConfig('Sticker | Heroic (Holo) | Rio 2022').base).toBe(
'Sticker | Heroic (Holo) | Rio 2022'
);
expect(parseConfig('Paris 2023 Mirage Souvenir Package').souvenir).toBe(false);
expect(parseConfig('StatTrak™ Music Kit | Daniel Sadowski, Crimson Assault').base).toBe(
'Music Kit | Daniel Sadowski, Crimson Assault'
);
});
});
describe('slugify', () => {
it('makes stable slugs', () => {
expect(slugify('★ Karambit | Doppler')).toBe('karambit-doppler');
expect(slugify('AK-47 | Redline')).toBe('ak-47-redline');
});
});
describe('configRank', () => {
it('sorts normal, then StatTrak, by wear and phase', () => {
const a = configRank({ stattrak: false, souvenir: false, wear: 'Factory New', phase: 'Ruby' });
const b = configRank({ stattrak: false, souvenir: false, wear: 'Minimal Wear', phase: null });
const c = configRank({ stattrak: true, souvenir: false, wear: 'Factory New', phase: null });
expect(a).toBeLessThan(b);
expect(b).toBeLessThan(c);
});
});

View File

@@ -0,0 +1,87 @@
// Pure helpers shared by scripts/cs-prices.ts and the item pages: how a market hash name maps to
// its skin (the page that groups every wear, StatTrak, Souvenir and phase config).
export const wearOrder = [
'Factory New',
'Minimal Wear',
'Field-Tested',
'Well-Worn',
'Battle-Scarred'
] as const;
export const wearShort: Record<string, string> = {
'Factory New': 'FN',
'Minimal Wear': 'MW',
'Field-Tested': 'FT',
'Well-Worn': 'WW',
'Battle-Scarred': 'BS'
};
export const phaseOrder = [
'Phase 1',
'Phase 2',
'Phase 3',
'Phase 4',
'Ruby',
'Sapphire',
'Black Pearl',
'Emerald'
] as const;
const wearSuffix = / \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$/;
export type Config = {
starred: boolean;
stattrak: boolean;
souvenir: boolean;
wear: string | null;
/** Name without ★, StatTrak™, Souvenir or wear: the skin grouping key. */
base: string;
/** Display name of the skin: base with ★ for knives and gloves. */
display: string;
};
export function parseConfig(marketHashName: string): Config {
let name = marketHashName.trim();
const starred = name.startsWith('★ ');
if (starred) name = name.slice(2);
let stattrak = false;
let souvenir = false;
if (name.startsWith('StatTrak™ ')) {
stattrak = true;
name = name.slice('StatTrak™ '.length);
}
if (name.startsWith('Souvenir ') && name.includes(' | ')) {
souvenir = true;
name = name.slice('Souvenir '.length);
}
const wear = wearSuffix.exec(name)?.[1] ?? null;
if (wear) name = name.replace(wearSuffix, '');
return { starred, stattrak, souvenir, wear, base: name, display: starred ? `★ ${name}` : name };
}
export function slugify(value: string) {
return (
value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/★/g, '')
.replace(/™/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120) || 'item'
);
}
/** Order configs: normal before StatTrak before Souvenir, then wear, then phase. */
export function configRank(row: {
stattrak: boolean;
souvenir: boolean;
wear: string | null;
phase: string | null;
}) {
const variant = row.souvenir ? 2 : row.stattrak ? 1 : 0;
const wear = row.wear ? wearOrder.indexOf(row.wear as (typeof wearOrder)[number]) + 1 : 0;
const phase = row.phase ? phaseOrder.indexOf(row.phase as (typeof phaseOrder)[number]) + 1 : 0;
return variant * 1000 + wear * 20 + phase;
}

View File

@@ -105,14 +105,17 @@
)
.join(' ');
}
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
/** Actual part through `projectedFrom` and projected part from the last actual point onward. */
function segments(item: ChartSeries) {
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
if (!item.projectedFrom) return { actual: item.points, projected: [] };
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
return split < 0
? { solid: [], dashed: item.points }
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
? { actual: [], projected: item.points }
: { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
}
/** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
const lineWidth = (item: ChartSeries, projected: boolean) =>
projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
function pointAt(points: ChartPoint[], date: string) {
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
return points.findLast((point) => point.date <= date) ?? null;
@@ -236,16 +239,17 @@
{/if}
{#each series as item (item.label)}
{@const parts = segments(item)}
{#each [parts.solid, parts.dashed] as points, index (index)}
{#each [parts.actual, parts.projected] as points, index (index)}
{#if points.length > 1}
<path
d={path(points)}
fill="none"
stroke={item.color}
stroke-width={item.emphasis ? 3 : 1.75}
stroke-dasharray={index ? '6 5' : undefined}
stroke-linecap={index ? 'butt' : 'round'}
stroke-width={lineWidth(item, index === 1)}
stroke-opacity={index ? 0.8 : 1}
stroke-linecap="round"
stroke-linejoin="round"
data-line={index ? 'projected' : 'actual'}
/>
{/if}
{/each}
@@ -306,16 +310,16 @@
</div>
{/if}
</div>
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
<figcaption class="legend">
{#each series as item (item.label)}
<span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
>
{/each}
{#if series.some((item) => item.dashedFrom)}
<span class="line-key first"><span class="key solid"></span>Actual</span>
<span class="line-key"><span class="key dashed"></span>Projected</span>
{#if series.some((item) => item.projectedFrom)}
<span class="line-key first"><span class="key actual"></span>Actual</span>
<span class="line-key"><span class="key projected"></span>Projected</span>
{/if}
</figcaption>
{/if}
@@ -419,10 +423,12 @@
.key {
display: inline-block;
width: 18px;
border-top: 2px solid var(--site-muted);
border-top: 3px solid var(--site-muted);
border-radius: 2px;
}
.key.dashed {
border-top-style: dashed;
.key.projected {
border-top-width: 1.5px;
opacity: 0.8;
}
.swatch {
display: inline-block;

View File

@@ -43,6 +43,7 @@
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
{ key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
{ key: 'calmYears', label: 'Calm start (no crashes)', unit: 'yr' },
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
]
@@ -61,6 +62,7 @@
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
{ key: 'salaryCapGrowthPercent', label: 'Plateau growth (real)', unit: '%/yr' },
{ key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
@@ -101,7 +103,21 @@
: 'Not by the end';
let active = $derived(params[selected]);
let result = $derived(results[selected]);
let normalReturn = $derived(marketReturns(active).normal);
let markets = $derived(marketReturns(active, result.years[0]?.year));
let normalReturn = $derived(markets.normal);
/** First crash after any calm start. */
let firstCrash = $derived.by(() => {
let year = active.firstCrashYear;
const every = Math.max(1, Math.round(active.crashEveryYears));
while (markets.calmUntil !== null && year <= markets.calmUntil) year += every;
return year;
});
/** Crash and correction years in the first ten years, for the explanation. */
let shocks = $derived(
Array.from({ length: 11 }, (_, offset) => (result.years[0]?.year ?? 0) + offset)
.map((year) => ({ year, ...markets.yearReturn(year) }))
.filter((row) => row.kind === 'crash' || row.kind === 'correction')
);
/** Salary path rows through the retirement year. */
let career = $derived(
result.years.filter(
@@ -163,11 +179,24 @@
<p class="muted scenario-portfolio">
<strong>{active.label} portfolio:</strong>
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
{#if markets.calmUntil !== null}No crash or down year through {markets.calmUntil}: those years
earn the {active.returnPercent}% average.
{/if}
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
from {active.firstCrashYear}{active.correctionPercent !== 0
from {firstCrash}{active.correctionPercent !== 0
? `, a ${active.correctionPercent}% correction halfway between`
: ''}, and {percent(normalReturn)} in the other years so it averages out.
: ''}, and {percent(normalReturn)} in the other years so it averages out{markets.calmUntil !==
null
? ` (from ${markets.calmUntil + 1})`
: ''}. A crash drops over March–October and takes about {Math.ceil(
Math.log(1 / (1 + active.crashPercent / 100)) / Math.log(1 + normalReturn)
)} years of normal returns to recover.
{#if shocks.length}Next ten years: {shocks
.map((row) => `${row.year} ${row.kind} ${Math.round(row.rate * 100)}%`)
.join(', ')}.{:else}No crash or correction in the next ten years.{/if}
{:else}No crashes modelled.{/if}
{#if active.salaryCapGrowthPercent > 0}The salary plateau keeps rising {active.salaryCapGrowthPercent}%/yr
above inflation, so pay never stalls.{/if}
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
the rest of the spending is sold from investments.
@@ -180,9 +209,10 @@
Joint items from the recurring list in full until the rent starts, then your share, while your
partner’s income pays hers, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
grow with inflation; spending grows at the spending rate for ten years, then with inflation.
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
grow with inflation (the plateau can grow faster, see above); spending grows at the spending
rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
Salary bands (base per year, excl. 8% holiday pay): medior €52–68k, senior €65–91k, lead
€79–110k (<a
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
rel="external noreferrer">Chase IT 2026</a
>,

View File

@@ -207,7 +207,7 @@ describe('money tracker calculations', () => {
}
];
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
expect(total.dashedFrom).toBe('2026-09-23');
expect(total.projectedFrom).toBe('2026-09-23');
expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 },

View File

@@ -15,8 +15,8 @@ export type ChartSeries = {
color: string;
points: ChartPoint[];
emphasis?: boolean;
/** Points after this date are drawn dashed (projected). */
dashedFrom?: string;
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
projectedFrom?: string;
};
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
@@ -283,7 +283,7 @@ export function wealthProjection(
/**
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
* date order), so a daily projection over decades keeps its spikes without plotting every day.
* The first and last points and the solid/dashed join are always kept.
* The first and last points and the actual/projected join are always kept.
*/
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2;
@@ -301,8 +301,8 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
const keep = new Uint8Array(line.points.length);
keep[0] = 1;
keep[line.points.length - 1] = 1;
if (line.dashedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
if (line.projectedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
if (join >= 0) keep[join] = 1;
}
let bucket = -1;
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
export type TimelineView = 'total' | 'split' | 'accounts';
/**
* Joins actual history (solid, through today) with the wealth projection (dashed, after
* Joins actual history (solid, through today) with the wealth projection (thin solid line, after
* today) so both read as one continuous line per measure.
*/
export function wealthTimeline(
@@ -379,7 +379,7 @@ export function wealthTimeline(
label: 'Total',
color: 'var(--site-text)',
emphasis: true,
dashedFrom: today,
projectedFrom: today,
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
};
if (view === 'accounts')
@@ -395,13 +395,13 @@ export function wealthTimeline(
{
label: 'Spendable',
color: '#46a758',
dashedFrom: today,
projectedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? [])
},
{
label: 'Invested',
color: '#8e4ec6',
dashedFrom: today,
projectedFrom: today,
points: join(sumKind(true), investedProjection?.points ?? [])
}
];

View File

@@ -189,6 +189,49 @@ describe('runScenario', () => {
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
it('keeps the optimistic start calm and its cycle after that', () => {
const markets = marketReturns(defaultScenarios.optimistic, 2026);
for (let year = 2026; year <= 2031; year++)
expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
expect(markets.yearReturn(2032).kind).toBe('normal');
expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
// No down year at all in the first five years of the run.
const run = runScenario(inputs, defaultScenarios.optimistic);
const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
});
it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
const shocks = (id: 'realistic' | 'pessimistic') => {
const markets = marketReturns(defaultScenarios[id], 2026);
return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
.map((year) => ({ year, ...markets.yearReturn(year) }))
.filter((row) => row.kind === 'crash');
};
expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
expect(defaultScenarios.realistic.calmYears).toBe(0);
expect(defaultScenarios.pessimistic.calmYears).toBe(0);
// Without a start year the calm start never applies.
expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
});
it('keeps the optimistic salary rising every working year', () => {
const long = { ...inputs, endDate: '2076-10-24' };
const years = runScenario(long, defaultScenarios.optimistic).years.filter(
(row) => row.year >= 2027 && row.role !== 'Retired'
);
expect(years.length).toBeGreaterThan(40);
years
.slice(1)
.forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
// A flat plateau stalls once it is reached.
const flatCap = runScenario(long, {
...defaultScenarios.optimistic,
salaryCapGrowthPercent: 0
});
const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
});
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {

View File

@@ -39,6 +39,11 @@ export type ScenarioParams = {
firstCrashYear: number;
/** Smaller correction halfway between crashes, percent (0 = none). */
correctionPercent: number;
/**
* Calm start: no crash or correction from this year through this many years ahead; those
* years earn the plain average return (0 = the crash cycle applies from the start).
*/
calmYears: number;
/** Interest on the cash buffer, percent per year. */
cashRatePercent: number;
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
@@ -62,6 +67,8 @@ export type ScenarioParams = {
hopGapMonths: number;
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
salaryCapEuros: number;
/** Real growth of that plateau per year, percent (0 = flat once reached). */
salaryCapGrowthPercent: number;
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
bonusPercentOfTarget: number;
/** Side income, gross today's euros per month, ramping over five years to the end value. */
@@ -97,6 +104,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 8,
firstCrashYear: 2027,
correctionPercent: -8,
calmYears: 0,
cashRatePercent: 0,
inflationPercent: 2.5,
gapMonths: 3,
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 15,
hopGapMonths: 3,
salaryCapEuros: 75000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 0,
sideStartEuros: 100,
sideEndEuros: 100,
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10,
calmYears: 0,
cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0,
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 20,
hopGapMonths: 0,
salaryCapEuros: 95000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 100,
sideStartEuros: 250,
sideEndEuros: 500,
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10,
firstCrashYear: 2030,
correctionPercent: -15,
calmYears: 5,
cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0,
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 22,
hopGapMonths: 0,
salaryCapEuros: 150000,
salaryCapGrowthPercent: 2,
bonusPercentOfTarget: 100,
sideStartEuros: 300,
sideEndEuros: 2500,
@@ -396,9 +409,11 @@ function monthEnd(first: string) {
/**
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
* return makes the whole cycle average out at the expected return.
* return makes the whole cycle average out at the expected return. With `calmYears`, the years
* from `startYear` through `startYear + calmYears` have no crash or correction and earn the plain
* average return; the cycle keeps its phase after that.
*/
export function marketReturns(params: ScenarioParams) {
export function marketReturns(params: ScenarioParams, startYear?: number) {
const pct = (value: number) => value / 100;
const every = Math.max(2, Math.round(params.crashEveryYears));
const crashes = params.crashPercent !== 0;
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
const normal = crashes
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
: pct(params.returnPercent);
const calmUntil =
startYear !== undefined && params.calmYears > 0
? startYear + Math.round(params.calmYears)
: null;
return {
normal,
calmUntil,
yearReturn(year: number) {
if (calmUntil !== null && year <= calmUntil)
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
if (!crashes || year < params.firstCrashYear)
return { kind: 'normal' as const, rate: normal };
const offset = (year - params.firstCrashYear) % every;
@@ -444,7 +466,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
const gapStart = addMonths(facts.contractEnd, 1);
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
const markets = marketReturns(params);
const markets = marketReturns(params, Number(inputs.today.slice(0, 4)));
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
const isHopYear = (year: number) =>
params.hopEveryYears > 0 &&
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
!(hopStart && month === 10)
)
gross *= 1 + pct(params.raisePercent);
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
const cap =
((params.salaryCapEuros * 100) / 12) *
deflator *
Math.pow(1 + pct(params.salaryCapGrowthPercent), Math.max(0, index) / 12);
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) {
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
// compounding gives the same monthly return.
const market = markets.yearReturn(year);
const monthRate =
market.kind === 'normal'
market.kind === 'normal' || market.kind === 'calm'
? Math.pow(1 + market.rate, 1 / 12) - 1
: month >= 3 && month <= 10
? Math.pow(1 + market.rate, 1 / 8) - 1
@@ -848,7 +873,7 @@ export function scenarioProjection(result: ScenarioResult, todaysEuros = false):
];
}
/** One dashed total line per scenario, for comparing them on the wealth chart. */
/** One projected total line per scenario, for comparing them on the wealth chart. */
export function scenarioCompareLines(
results: Record<ScenarioId, ScenarioResult>,
params: Record<ScenarioId, ScenarioParams>,
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
return scenarioIds.map((id) => ({
label: params[id].label,
color: scenarioColors[id],
dashedFrom: today,
projectedFrom: today,
points: results[id].points
.filter((point) => point.date > today)
.map((point) => ({

View File

@@ -24,6 +24,24 @@ const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
});
describe('bank transaction mapping', () => {
it('tags interest and dividend payouts, nothing else', () => {
const credit = (remittance: string[], code?: string) =>
mapBankTransaction(
tx({
credit_debit_indicator: 'CRDT',
creditor: undefined,
debtor: { name: 'Revolut' },
remittance_information: remittance,
bank_transaction_code: code ? { code } : undefined
})
)!.category;
expect(credit(['Savings vault interest'])).toBe('interest');
expect(credit(['Interest PAID EUR Class R IE000AZVL3K0'])).toBe('interest');
expect(credit(['Revolut'], 'INTEREST')).toBe('interest');
expect(credit(['Dividend Payment: VWRL'])).toBe('dividend');
expect(credit(['From JUHEUI KIM & STAN RUNGE'], 'TRANSFER')).toBeNull();
expect(mapBankTransaction(tx({ remittance_information: ['Interest'] }))!.category).toBeNull();
});
it('parses decimal amounts exactly', () => {
expect(amountToCents('12.34')).toBe(1234);
expect(amountToCents('-0.5')).toBe(-50);

View File

@@ -11,6 +11,7 @@ import type {
SessionResponse
} from './enable-banking';
import { EnableBankingError } from './enable-banking';
import { investmentIncomeCategory } from './investment-income';
import { findSumMatch } from './reconcile';
export type MappedTransaction = {
@@ -21,6 +22,8 @@ export type MappedTransaction = {
counterparty: string | null;
amountCents: number;
bookingStatus: string;
/** 'interest' / 'dividend' for investment income payouts, else null. */
category: string | null;
raw: BankTransaction;
};
@@ -79,6 +82,17 @@ export function mapBankTransaction(tx: BankTransaction): MappedTransaction | nul
counterparty,
amountCents,
bookingStatus: tx.status ?? 'BOOK',
category: investmentIncomeCategory({
amountCents,
kind: tx.bank_transaction_code?.code,
texts: [
description,
...remittance,
tx.bank_transaction_code?.description,
tx.bank_transaction_code?.sub_code,
tx.note
]
}),
raw: tx
};
}
@@ -435,10 +449,11 @@ export async function syncBankAccounts(
for (const { row } of absorbed)
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
for (const { id, tx } of updates) {
// Keep Stan's own description/category; attach the bank identity.
// Keep Stan's own description/category (only fill an empty one); attach the bank identity.
await q`
update money.transactions set external_id = ${tx.externalId},
counterparty = coalesce(counterparty, ${tx.counterparty}),
category = coalesce(category, ${tx.category}),
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
raw = ${q.json(tx.raw as never)}
where id = ${id} and external_id is null`;
@@ -447,10 +462,10 @@ export async function syncBankAccounts(
await q`
insert into money.transactions
(user_id, account_id, date, description, amount_cents, external_id, source,
booking_status, counterparty, raw, value_date)
booking_status, counterparty, category, raw, value_date)
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
${tx.counterparty}, ${tx.category}, ${q.json(tx.raw as never)}, ${tx.valueDate})
on conflict (account_id, external_id) do nothing`;
}
});

275
src/lib/server/cs-prices.ts Normal file
View File

@@ -0,0 +1,275 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import { configRank, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
// Read side of the CS2 price archive (cs.*): skin search, a skin's configs with their latest
// price per source, and one config's daily history. USD streams are shown in EUR using the
// ECB rate of that day (cs.fx_rates); `native` keeps the source's own currency.
export type Stream = { id: number; source: string; currency: string; kind: string; label: string };
export const kinds = [
['weapon', 'Weapons'],
['knife', 'Knives'],
['gloves', 'Gloves'],
['sticker', 'Stickers'],
['container', 'Cases'],
['agent', 'Agents'],
['charm', 'Charms'],
['patch', 'Patches'],
['music-kit', 'Music kits'],
['graffiti', 'Graffiti'],
['collectible', 'Pins & coins'],
['key', 'Keys']
] as const;
export async function streams(): Promise<Stream[]> {
const rows = await db.execute(sql`SELECT id, source, currency, kind,
coalesce(label, source || ' ' || replace(kind, '-', ' ')) AS label
FROM cs.price_streams WHERE id IS NOT NULL ORDER BY id`);
return (rows as unknown as Stream[]).map((row) => ({ ...row, id: Number(row.id) }));
}
export async function overview() {
const [row] = (await db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.skins) AS skins,
(SELECT count(*)::int FROM cs.items) AS configs,
(SELECT max(day)::text FROM cs.daily_prices) AS latest_day,
(SELECT count(*)::int FROM cs.daily_prices
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_rows,
(SELECT count(DISTINCT item_id)::int FROM cs.daily_prices
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_configs,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class
WHERE oid = 'cs.daily_prices'::regclass)::bigint AS rows,
(SELECT min(day)::text FROM cs.daily_prices) AS earliest_day,
(SELECT max(last_fetched_at) FROM cs.price_streams) AS fetched_at`)) as unknown as {
skins: number;
configs: number;
latest_day: string | null;
latest_rows: number;
latest_configs: number;
rows: string;
earliest_day: string | null;
fetched_at: Date | null;
}[];
return row;
}
export type SkinHit = {
slug: string;
name: string;
kind: string;
image: string | null;
rarity: string | null;
rarity_color: string | null;
configs: number;
from_cents: number | null;
listings: number | null;
};
/** Skins matching every word of `q`, with today's cheapest EUR listing across their configs. */
export async function searchSkins(q: string, kind: string, sort: string, limit = 60, offset = 0) {
const words = q
.trim()
.slice(0, 100)
.split(/\s+/)
.filter(Boolean)
.slice(0, 6)
.map(
(word) => `%${word.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
);
const filter = words.length
? sql.join(
words.map((word) => sql`s.name ILIKE ${word} ESCAPE '\\'`),
sql` AND `
)
: sql`true`;
const kindFilter =
kind && kinds.some(([value]) => value === kind) ? sql`s.kind = ${kind}` : sql`true`;
const order =
sort === 'price'
? sql`p.from_cents DESC NULLS LAST, s.name`
: sort === 'name'
? sql`s.name`
: sql`p.listings DESC NULLS LAST, s.name`;
const rows = await db.execute(sql`
WITH listing AS (
SELECT id FROM cs.price_streams WHERE source = 'skinport' AND kind = 'lowest-listing'
), latest AS (
SELECT max(day) AS day FROM cs.daily_prices WHERE stream_id = (SELECT id FROM listing)
), p AS (
SELECT i.skin_id, min(dp.price_cents)::int AS from_cents, sum(dp.volume)::int AS listings
FROM cs.daily_prices dp JOIN cs.items i ON i.id = dp.item_id
WHERE dp.stream_id = (SELECT id FROM listing) AND dp.day = (SELECT day FROM latest)
GROUP BY i.skin_id
)
SELECT s.slug, s.name, s.kind, s.image, s.rarity, s.rarity_color, p.from_cents, p.listings,
(SELECT count(*)::int FROM cs.items i WHERE i.skin_id = s.id) AS configs
FROM cs.skins s LEFT JOIN p ON p.skin_id = s.id
WHERE ${filter} AND ${kindFilter}
ORDER BY ${words.length ? sql`(s.name ILIKE ${words[0].slice(1)} ESCAPE '\\') DESC,` : sql``} ${order}
LIMIT ${Math.min(120, Math.max(1, limit))} OFFSET ${Math.min(50_000, Math.max(0, offset))}`);
return rows as unknown as SkinHit[];
}
export type ConfigRow = {
id: number;
market_hash_name: string;
phase: string | null;
wear: string | null;
stattrak: boolean;
souvenir: boolean;
icon_url: string | null;
prices: {
stream: number;
cents: number;
eur_cents: number;
volume: number | null;
day: string;
}[];
};
export type Variant = 'normal' | 'stattrak' | 'souvenir';
export const variantOf = (row: { stattrak: boolean; souvenir: boolean }): Variant =>
row.souvenir ? 'souvenir' : row.stattrak ? 'stattrak' : 'normal';
const shortToWear = Object.fromEntries(Object.entries(wearShort).map(([wear, s]) => [s, wear]));
export async function loadSkin(slug: string) {
const [skin] = (await db.execute(sql`SELECT id, slug, name, kind, weapon, pattern, category,
rarity, rarity_color, image, min_float, max_float, collections
FROM cs.skins WHERE slug = ${slug}`)) as unknown as {
id: string;
slug: string;
name: string;
kind: string;
weapon: string | null;
pattern: string | null;
category: string | null;
rarity: string | null;
rarity_color: string | null;
image: string | null;
min_float: number | null;
max_float: number | null;
collections: string[] | null;
}[];
if (!skin) return null;
// Latest price per config and stream within the last 10 days, converted to EUR.
const configs = (await db.execute(sql`
WITH fx AS (SELECT usd_eur FROM cs.fx_rates ORDER BY day DESC LIMIT 1)
SELECT i.id, i.market_hash_name, i.phase, i.wear, i.stattrak, i.souvenir, i.icon_url,
coalesce((SELECT json_agg(json_build_object('stream', l.stream_id, 'cents', l.price_cents,
'eur_cents', CASE WHEN st.currency = 'USD'
THEN round(l.price_cents * coalesce((SELECT usd_eur FROM fx), 0.92))::int
ELSE l.price_cents END,
'volume', l.volume, 'day', l.day::text) ORDER BY l.stream_id)
FROM (SELECT DISTINCT ON (dp.stream_id) dp.stream_id, dp.price_cents, dp.volume, dp.day
FROM cs.daily_prices dp WHERE dp.item_id = i.id AND dp.day >= current_date - 10
ORDER BY dp.stream_id, dp.day DESC) l
JOIN cs.price_streams st ON st.id = l.stream_id), '[]'::json) AS prices
FROM cs.items i WHERE i.skin_id = ${skin.id}`)) as unknown as ConfigRow[];
configs.sort((a, b) => configRank(a) - configRank(b));
return { skin, configs };
}
/** Pick the config from ?v=&wear=&phase=, falling back to the cheapest priced normal config. */
export function pickConfig(configs: ConfigRow[], params: URLSearchParams) {
const variant = params.get('v') as Variant | null;
const wear = shortToWear[params.get('wear') ?? ''] ?? null;
const phase = params.get('phase') || null;
// Default to the most traded normal config (most listings/sales across sources).
const liquidity = (row: ConfigRow) => row.prices.reduce((sum, p) => sum + (p.volume ?? 0), 0);
const fallback = () =>
configs
.filter((row) => row.prices.length && !row.phase && variantOf(row) === 'normal')
.sort((a, b) => liquidity(b) - liquidity(a))[0] ??
configs.find((row) => row.prices.length) ??
configs[0] ??
null;
if (!variant && !params.has('wear') && !params.has('phase')) return fallback();
const exact = configs.find(
(row) =>
(!variant || variantOf(row) === variant) &&
(params.has('wear') ? row.wear === wear : true) &&
(params.has('phase') ? row.phase === phase : !row.phase)
);
if (exact) return exact;
const near = configs.find(
(row) => (!variant || variantOf(row) === variant) && (!wear || row.wear === wear)
);
return near ?? fallback();
}
export type SeriesPoint = [string, number];
/** Daily EUR series per stream for one config. Doppler phases also get the phase-blind
* listing's streams they lack (e.g. Steam has no phases), labelled as "any phase". */
export async function loadSeries(config: ConfigRow, from: string | null) {
const ids = [config.id];
if (config.phase) {
const [base] = (await db.execute(sql`SELECT id FROM cs.items
WHERE market_hash_name = ${config.market_hash_name} AND phase IS NULL`)) as unknown as {
id: number;
}[];
if (base) ids.push(base.id);
}
const rows = (await db.execute(sql`
SELECT dp.item_id, dp.stream_id, dp.day::text AS day, dp.volume,
CASE WHEN st.currency = 'USD' THEN round(dp.price_cents * coalesce(
(SELECT usd_eur FROM cs.fx_rates f WHERE f.day <= dp.day ORDER BY f.day DESC LIMIT 1),
(SELECT usd_eur FROM cs.fx_rates ORDER BY day LIMIT 1), 0.92))::int
ELSE dp.price_cents END AS eur_cents
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
WHERE dp.item_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)}) ${from ? sql`AND dp.day >= ${from}::date` : sql``}
ORDER BY dp.day`)) as unknown as {
item_id: number;
stream_id: number;
day: string;
volume: number | null;
eur_cents: number;
}[];
const own = new Set(rows.filter((r) => r.item_id === config.id).map((r) => r.stream_id));
const series = new Map<string, { stream: number; anyPhase: boolean; points: SeriesPoint[] }>();
for (const row of rows) {
const anyPhase = row.item_id !== config.id;
if (anyPhase && own.has(row.stream_id)) continue;
const key = `${row.stream_id}:${anyPhase}`;
const entry = series.get(key) ?? { stream: Number(row.stream_id), anyPhase, points: [] };
entry.points.push([row.day, row.eur_cents]);
series.set(key, entry);
}
const windows = (await db.execute(sql`SELECT item_id, source, currency, span, median_cents,
avg_cents, min_cents, max_cents, volume FROM cs.price_windows
WHERE item_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)})`)) as unknown as {
item_id: number;
source: string;
currency: string;
span: string;
median_cents: number | null;
avg_cents: number | null;
min_cents: number | null;
max_cents: number | null;
volume: number | null;
}[];
const [fx] = (await db.execute(
sql`SELECT usd_eur::float AS rate, day::text FROM cs.fx_rates ORDER BY day DESC LIMIT 1`
)) as unknown as { rate: number; day: string }[];
return {
series: [...series.values()],
windows: windows
.filter(
(w) =>
w.item_id === config.id ||
!windows.some((o) => o.item_id === config.id && o.source === w.source)
)
.map((w) => ({ ...w, anyPhase: w.item_id !== config.id })),
fx: fx ?? null
};
}
export { wearOrder, wearShort };

View File

@@ -5,11 +5,45 @@ import {
text,
timestamp,
uniqueIndex,
bigint
bigint,
boolean,
date,
real,
smallint,
numeric
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { csSchema } from './schemas';
import { id } from './helpers';
// One finish or item, grouping all of its market configs (wear, StatTrak, Souvenir, phase).
// Ids come from ByMykel/CSGO-API (skin_id / item id); items only seen in price feeds get `mkt-<slug>`.
export const csSkins = csSchema.table(
'skins',
{
id: text('id').primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(),
kind: text('kind').notNull(),
weapon: text('weapon'),
pattern: text('pattern'),
category: text('category'),
rarity: text('rarity'),
rarityColor: text('rarity_color'),
image: text('image'),
minFloat: real('min_float'),
maxFloat: real('max_float'),
collections: text('collections').array(),
source: text('source').notNull().default('bymykel'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('skins_slug_idx').on(table.slug),
index('skins_name_idx').on(table.name),
index('skins_kind_idx').on(table.kind)
]
);
// A Steam market hash name identifies a listing, including wear and special variants.
export const csMarketItems = csSchema.table(
'items',
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
name: text('name').notNull(),
iconUrl: text('icon_url'),
itemType: text('item_type'),
// Catalog link and config facets. Doppler phases share a market hash name, so a phase
// row is its own config next to the phase-less listing.
skinId: text('skin_id').references(() => csSkins.id, { onDelete: 'set null' }),
phase: text('phase'),
wear: text('wear'),
stattrak: boolean('stattrak').notNull().default(false),
souvenir: boolean('souvenir').notNull().default(false),
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('items_hash_idx').on(table.marketHashName),
index('items_name_idx').on(table.name)
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
index('items_name_idx').on(table.name),
index('items_skin_idx').on(table.skinId)
]
);
@@ -82,9 +124,64 @@ export const csPriceStreams = csSchema.table(
{
source: text('source').notNull(),
currency: text('currency').notNull(),
kind: text('kind').notNull()
kind: text('kind').notNull(),
id: smallint('id').generatedByDefaultAsIdentity(),
label: text('label'),
lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }),
lastRows: integer('last_rows'),
lastError: text('last_error')
},
(table) => [
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] }),
uniqueIndex('price_streams_id_idx').on(table.id)
]
);
// One price per config per stream per UTC day (idempotent upsert). Prices stay in the stream's
// own currency; fx_rates converts USD streams to EUR for display.
export const csDailyPrices = csSchema.table(
'daily_prices',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
streamId: smallint('stream_id').notNull(),
day: date('day').notNull(),
priceCents: integer('price_cents').notNull(),
volume: integer('volume')
},
(table) => [
primaryKey({ name: 'daily_prices_pkey', columns: [table.itemId, table.streamId, table.day] }),
index('daily_prices_day_idx').on(table.day, table.streamId)
]
);
// Rolling sale aggregates (24h/7d/30d/90d) as reported by the source, latest snapshot only.
export const csPriceWindows = csSchema.table(
'price_windows',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
source: text('source').notNull(),
currency: text('currency').notNull(),
span: text('span').notNull(),
medianCents: integer('median_cents'),
avgCents: integer('avg_cents'),
minCents: integer('min_cents'),
maxCents: integer('max_cents'),
volume: integer('volume'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
primaryKey({
name: 'price_windows_pkey',
columns: [table.itemId, table.source, table.span]
})
]
);
export const csFxRates = csSchema.table('fx_rates', {
day: date('day').primaryKey(),
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
});

View File

@@ -25,6 +25,7 @@ const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as co
const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
@@ -36,6 +37,11 @@ export const worldtodoRequestStatus = worldtodoSchema.enum(
WORLDTODO_REQUEST_STATUSES
);
export const worldtodoLocationPrecision = worldtodoSchema.enum(
'location_precision',
WORLDTODO_LOCATION_PRECISIONS
);
export const worldtodoCategories = worldtodoSchema.table('categories', {
slug: text('slug').primaryKey(),
label: text('label').notNull(),
@@ -61,6 +67,13 @@ export const worldtodoTasks = worldtodoSchema.table(
countryCode: text('country_code'),
lat: doublePrecision('lat'),
lon: doublePrecision('lon'),
/**
* How the map treats lat/lon: street and city tasks are exact pins, region and country
* pins sit at a rough centre, planet tasks (or no coordinates) get no pin. Set from scale
* and lat/lon by the tasks_location_precision trigger (hand-written in the migration), so
* it never drifts from the data.
*/
locationPrecision: worldtodoLocationPrecision('location_precision'),
status: worldtodoStatus('status').notNull().default('open'),
sourceUrls: text('source_urls').array().notNull(),
/** Date of the main source as given ('2026-09', '2026-10-21'). */

View File

@@ -48,7 +48,11 @@ export type BankTransaction = {
creditor_account?: { iban?: string | null } | null;
debtor_account?: { iban?: string | null } | null;
remittance_information?: string[] | null;
bank_transaction_code?: { description?: string | null } | null;
bank_transaction_code?: {
code?: string | null;
sub_code?: string | null;
description?: string | null;
} | null;
merchant_category_code?: string | null;
note?: string | null;
[key: string]: unknown;

View File

@@ -23,6 +23,10 @@ export type GoalMetricDetail = {
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m';
/** money.transactions.category values that count as investment income (agreed with MoneyMan).
* Dividends, interest (incl. Revolut savings interest) and distributions are recorded as one
* of these; deposits, transfers, investment sales and unrealised gains are NOT. */
export const INVESTMENT_INCOME_CATEGORIES = ['dividend', 'interest'];
const TZ = 'Europe/Amsterdam';
type SourceRow = {
@@ -54,6 +58,18 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
select value, detail, computed_at, error from tasks.metric_sources
where key = ${SIDE_INCOME_SOURCE}`;
// Investment income: trailing 12 months of money.transactions with amount_cents > 0 and
// category in INVESTMENT_INCOME_CATEGORIES, on any account except Joint (shared), ÷ 12.
const [inv] = await sql<{ cents: string; n: number }[]>`
select coalesce(sum(t.amount_cents), 0) as cents, count(*)::int as n
from money.transactions t
join money.accounts a on a.id = t.account_id
where t.amount_cents > 0
and t.category = any(${INVESTMENT_INCOME_CATEGORIES})
and a.scope <> 'joint'
and t.date > ((now() at time zone ${TZ})::date - interval '12 months')
and t.date <= (now() at time zone ${TZ})::date`;
const parts: GoalMetricPart[] = rows.map((r) => ({
key: `recurring-${r.id}`,
label: r.name,
@@ -71,6 +87,12 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
: 'not fetched yet',
asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null
});
parts.push({
key: 'investment-income',
label: 'Investment income',
value: round2(Number(inv?.cents ?? 0) / 100 / 12),
note: `${inv?.n ?? 0} dividend/interest payments, last 12 months ÷ 12`
});
return {
value: round2(parts.reduce((s, p) => s + p.value, 0)),
detail: {
@@ -87,7 +109,7 @@ export const GOAL_METRICS: Record<
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
> = {
'own-net-income-monthly': {
label: 'Own net income / month (salary + side income)',
label: 'Own net income / month (salary + side + investment income)',
compute: ownNetIncomeMonthly
}
};

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { investmentIncomeCategory as cat } from './investment-income';
const pos = (texts: string[], kind?: string) => cat({ amountCents: 123, kind, texts });
describe('investment income tagging', () => {
it('tags interest payouts from real and Revolut wording', () => {
for (const text of [
'Interest',
'Knab: Rente',
'Interest PAID EUR Class R IE000AZVL3K0',
'Savings Interest',
'Savings vault interest',
'Gross interest',
'Net interest',
'Interest earned',
'Rentebijschrijving'
])
expect(pos([text]), text).toBe('interest');
expect(pos(['Revolut'], 'Interest')).toBe('interest');
expect(pos(['Revolut', 'Interest earned on your Savings'], 'TRANSFER')).toBe('interest');
});
it('tags dividend payouts', () => {
expect(pos(['Dividend Payment: GOOGL'])).toBe('dividend');
expect(pos(['VWRL dividend'])).toBe('dividend');
expect(pos(['Dividenduitkering'])).toBe('dividend');
expect(pos(['Broker'], 'DIVIDEND')).toBe('dividend');
});
it('ignores costs, reinvestments, card rows and look-alikes', () => {
expect(cat({ amountCents: -1043, texts: ['Interest Reinvested Class R EUR'] })).toBeNull();
expect(cat({ amountCents: -2, texts: ['Service Fee Charged EUR Class'] })).toBeNull();
expect(cat({ amountCents: -500, texts: ['Dividend tax'] })).toBeNull();
expect(cat({ amountCents: 0, texts: ['Interest'] })).toBeNull();
expect(pos(['Interest Cafe'], 'Card Refund')).toBeNull();
expect(pos(['Interest Cafe Utrecht'])).toBeNull();
expect(pos(['From Juhee'])).toBeNull();
expect(pos(['Tikkie: Rente lening terug'])).toBeNull();
expect(pos(['Rentenaar BV'])).toBeNull();
expect(pos(['Salaris Cargill'])).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
// Tags investment income on import so the €10k/month goal bar (goal-metrics/core.ts,
// INVESTMENT_INCOME_CATEGORIES) can find it: positive interest payouts -> 'interest',
// positive dividend payouts -> 'dividend'. Pure, shared by the bank sync and statement imports.
export type IncomeCategory = 'interest' | 'dividend';
/** Revolut "Type" / bank transaction codes that are never income payouts. */
const NOT_INCOME_KIND = /^(card[ _-]?(payment|refund)|exchange|atm|fee|charge)$/i;
const DIVIDEND = /\bdividend(?:s|en|uitkering)?\b/i;
/**
* Interest wording seen in real rows and Revolut exports:
* "Interest" (ABN), "Knab: Rente", Revolut "Interest PAID EUR Class R IE000…",
* "Savings Interest", "Savings vault interest", "Gross/Net interest", "Rentebijschrijving".
* Payout-only: "Interest Reinvested" / "Service Fee Charged" are negative and so never match.
*/
const INTEREST = [
// Whole description is (a bank prefix plus) the word interest/rente, optionally qualified.
/^(?:[\p{L}0-9 .&'-]{1,30}:\s*)?(?:(?:gross|net|savings|vault|credit)\s+)?(?:interest|rente|creditrente)(?:\s+(?:paid|earned|payout|payment|credited|bijschrijving|uitbetaling)\b.*)?$/iu,
/^interest\s+paid\b/i,
/\b(?:savings|vault|pocket|spaar)\s*(?:account\s+)?interest\b/i,
/\binterest\s+(?:earned|payout|credited)\b/i,
/\brente(?:bijschrijving|uitkering|vergoeding)\b/i
];
export type IncomeInput = {
amountCents: number;
/** Revolut statement "Type" or the bank's transaction code. */
kind?: string | null;
/** Description, remittance lines, bank code description, note… */
texts: (string | null | undefined)[];
};
export function investmentIncomeCategory({
amountCents,
kind,
texts
}: IncomeInput): IncomeCategory | null {
if (!(amountCents > 0)) return null;
const k = kind?.trim() ?? '';
if (k && NOT_INCOME_KIND.test(k)) return null;
if (DIVIDEND.test(k)) return 'dividend';
if (/\binterest\b/i.test(k)) return 'interest';
const lines = texts.map((t) => t?.replace(/\s+/g, ' ').trim()).filter((t): t is string => !!t);
if (lines.some((t) => DIVIDEND.test(t))) return 'dividend';
if (lines.some((t) => INTEREST.some((re) => re.test(t)))) return 'interest';
return null;
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
statementCategory,
checkBalances,
parseAmount,
parseRevolutCsv,
@@ -21,6 +22,19 @@ Card Payment,Current,2025-08-05 10:00:00,2025-08-05 11:00:00,Kiosk,-3.50,0.00,EU
`;
describe('revolut statement', () => {
it('tags interest and dividend rows by type or description', () => {
const row = (type: string, description: string, amountCents = 512) => ({
type,
description,
amountCents
});
expect(statementCategory(row('Interest', 'Savings vault interest'))).toBe('interest');
expect(statementCategory(row('', 'Interest PAID EUR Class R IE000AZVL3K0'))).toBe('interest');
expect(statementCategory(row('Dividend', 'VWRL'))).toBe('dividend');
expect(statementCategory(row('Transfer', 'To EUR Savings', -10000))).toBeNull();
expect(statementCategory(row('Card Payment', 'Interest Cafe'))).toBeNull();
expect(statementCategory(row('Transfer', 'From Juhee'))).toBeNull();
});
it('parses amounts in both notations', () => {
expect(parseAmount('1,234.56')).toBe(123456);
expect(parseAmount('-1.234,56')).toBe(-123456);

View File

@@ -1,3 +1,5 @@
import { investmentIncomeCategory } from './investment-income';
// Revolut account statement parsing + import planning (pure; DB work is in scripts/revolut-import.ts).
// Handles the app's CSV export (English or Dutch headers, , or ; separated), the Excel export
// (rows passed in as a string table) and, defensively, the text of the PDF statement.
@@ -341,3 +343,12 @@ export function statementExternalId(row: StatementRow, hash: (s: string) => stri
).slice(0, 24)
);
}
/** 'interest' / 'dividend' for a statement row that pays investment income, else null. */
export function statementCategory(row: Pick<StatementRow, 'amountCents' | 'type' | 'description'>) {
return investmentIncomeCategory({
amountCents: row.amountCents,
kind: row.type,
texts: [row.description]
});
}

View File

@@ -6,6 +6,7 @@ import {
EDITABLE_FIELDS,
RESERVED_SLUGS,
changedFields,
precisionFor,
type Category,
type PublicTask,
type RequestKind,
@@ -18,7 +19,8 @@ import {
const TASK_COLUMNS = sql`
t.id, t.slug, t.title, t.description, t.scale::text as scale, t.category,
t.location_name, t.country_code, t.lat, t.lon, t.status::text as status, t.source_urls,
t.location_name, t.country_code, t.lat, t.lon, t.location_precision::text as location_precision,
t.status::text as status, t.source_urls,
t.source_date, t.confidence, t.verification_note, t.as_of::text as as_of,
t.created_at, t.updated_at, t.deleted_at`;
@@ -38,17 +40,23 @@ function toDate(v: unknown): Date | null {
const iso = (v: unknown) => toDate(v)?.toISOString() ?? '';
export function toTask(r: Row): PublicTask {
const lat = r.lat === null || r.lat === undefined ? null : Number(r.lat);
const lon = r.lon === null || r.lon === undefined ? null : Number(r.lon);
const scale = r.scale as PublicTask['scale'];
return {
id: Number(r.id),
slug: String(r.slug),
title: String(r.title),
description: String(r.description),
scale: r.scale as PublicTask['scale'],
scale,
category: String(r.category),
locationName: String(r.location_name),
countryCode: (r.country_code as string | null) ?? null,
lat: r.lat === null || r.lat === undefined ? null : Number(r.lat),
lon: r.lon === null || r.lon === undefined ? null : Number(r.lon),
lat,
lon,
locationPrecision:
(r.location_precision as PublicTask['locationPrecision'] | null) ??
precisionFor(scale, lat, lon),
status: r.status as PublicTask['status'],
sourceUrls: (r.source_urls as string[]) ?? [],
sourceDate: (r.source_date as string | null) ?? null,

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import 'leaflet/dist/leaflet.css';
import type * as Leaflet from 'leaflet';
import { untrack } from 'svelte';
import { addThemedTiles, loadLeaflet } from './tiles';
import MapPin from 'lucide-svelte/icons/map-pin';
// Optional "Pick on map" for the request form: a click fills the latitude/longitude inputs
// (5 decimals, about a metre). The coordinates travel in the change request, so the owner
// sees them in the queue before anything is applied.
let { lat = $bindable(''), lon = $bindable('') }: { lat?: string; lon?: string } = $props();
let open = $state(false);
let el = $state<HTMLDivElement>();
let L: typeof Leaflet | null = null;
let map: Leaflet.Map | null = null;
let marker: Leaflet.CircleMarker | null = null;
const parsed = () => {
const a = Number(lat);
const b = Number(lon);
return lat !== '' && lon !== '' && Math.abs(a) <= 90 && Math.abs(b) <= 180
? ([a, b] as [number, number])
: null;
};
function place(at: [number, number] | null) {
if (!map || !L) return;
if (!at) {
marker?.remove();
marker = null;
return;
}
if (marker) marker.setLatLng(at);
else
marker = L.circleMarker(at, {
radius: 8,
color: '#fff',
weight: 2,
fillColor: '#ea580c',
fillOpacity: 1
}).addTo(map);
}
$effect(() => {
if (!open || !el) return;
const node = el;
let disposed = false;
let stopTiles = () => {};
loadLeaflet().then((Lf) => {
if (disposed) return;
L = Lf;
const start = untrack(parsed);
const m = Lf.map(node, { scrollWheelZoom: false, worldCopyJump: true, minZoom: 1 });
map = m;
stopTiles = addThemedTiles(Lf, m);
if (start) m.setView(start, 12);
else m.setView([30, 10], 1);
m.on('focus', () => m.scrollWheelZoom.enable());
m.on('blur', () => m.scrollWheelZoom.disable());
m.on('click', (e: Leaflet.LeafletMouseEvent) => {
m.scrollWheelZoom.enable();
const w = e.latlng.wrap();
lat = w.lat.toFixed(5);
lon = w.lng.toFixed(5);
});
untrack(() => place(start));
});
return () => {
disposed = true;
stopTiles();
map?.remove();
map = null;
marker = null;
};
});
// Typed coordinates move the marker too.
$effect(() => {
void lat;
void lon;
untrack(() => place(parsed()));
});
</script>
<div class="picker">
<button type="button" class="toggle" aria-expanded={open} onclick={() => (open = !open)}>
<MapPin size={14} aria-hidden="true" />{open ? 'Hide map' : 'Pick on map'}
</button>
{#if lat || lon}
<button
type="button"
class="toggle"
onclick={() => {
lat = '';
lon = '';
}}>Clear position</button
>
{/if}
{#if open}
<div class="mini" bind:this={el} aria-label="Click the map to set the position"></div>
<p class="help">
Click where the problem is; zoom in for a street. This fills the fields above.
</p>
{/if}
</div>
<style>
/* Keyless fallback (OSM tiles) darkened to sit in the dark theme. */
:global(.wt-tiles-dim) {
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6);
}
.picker {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: transparent;
color: var(--site-text);
font-size: 0.78rem;
cursor: pointer;
}
.toggle:hover,
.toggle:focus-visible {
border-color: var(--site-text);
}
.mini {
flex-basis: 100%;
height: 260px;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
overflow: hidden;
background: #0e0e0e;
cursor: crosshair;
z-index: 0;
}
.mini :global(.leaflet-container) {
cursor: crosshair;
}
.mini :global(.leaflet-control-attribution) {
font-size: 0.6rem;
}
.help {
flex-basis: 100%;
font-size: 0.72rem;
color: var(--site-muted);
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import IconTile from './IconTile.svelte';
import MapPicker from './MapPicker.svelte';
import { SCALE_ICONS } from './icons';
import {
SCALE_INFO,
@@ -212,6 +213,7 @@
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
</div>
</div>
<MapPicker bind:lat bind:lon />
</details>
<fieldset class="field" aria-describedby={described('source_urls', true)}>

View File

@@ -0,0 +1,760 @@
<script lang="ts">
import 'leaflet/dist/leaflet.css';
import type * as Leaflet from 'leaflet';
import { mount, unmount, untrack, type Component } from 'svelte';
import { categoryIcon } from './icons';
import { clusterPoints, coordKey, ringOffsets } from './cluster';
import { addThemedTiles, loadLeaflet } from './tiles';
import {
SCALE_INFO,
STATUS_LABELS,
WORLDTODO_SCALES,
type Category,
type PublicTask,
type Scale
} from './shared';
// The task map (List / Map toggle, tracker #4641): one pin per task with coordinates,
// coloured by category and sized by scale; clusters with counts when zoomed out; tasks on
// the same centroid fan out in a ring from zoom 6. Planet tasks never get a pin (the page
// shows them in the Global strip). Pins are focusable buttons; the list stays the full
// alternative to the map.
let {
tasks,
categories,
highlight = null,
fitKey = ''
}: {
tasks: PublicTask[];
categories: Category[];
/** Slug whose pin (or cluster) is emphasised, e.g. while hovering its list card. */
highlight?: string | null;
/** Refit the view when this changes (filters), not on live updates. */
fitKey?: string;
} = $props();
const BASE = '/projects/worldwide-todo';
const PIN: Record<Scale, number> = { street: 16, city: 20, country: 26, region: 32, planet: 36 };
const FOCUS_ZOOM: Record<Scale, number> = {
street: 16,
city: 12,
country: 6,
region: 5,
planet: 3
};
const SPREAD_FROM = 6;
const UNCLUSTER_FROM = 10;
let el: HTMLDivElement;
let L: typeof Leaflet | null = null;
let map: Leaflet.Map | null = null;
let layer: Leaflet.LayerGroup | null = null;
let ready = $state(false);
let failed = $state(false);
let scrollOn = $state(false);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const pins = new Map<string, Leaflet.Marker>();
/** slug → the element showing it (its own pin or the cluster that holds it). */
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const shownBy = new Map<string, HTMLElement>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const clusterOf = new Map<string, { at: Leaflet.LatLng; members: PublicTask[] }>();
const located = $derived(
tasks.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
);
const catBySlug = $derived(new Map(categories.map((c) => [c.slug, c])));
const usedCats = $derived(categories.filter((c) => located.some((t) => t.category === c.slug)));
const esc = (s: string) =>
s.replace(
/[&<>"']/g,
(ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]!
);
const colourOf = (t: PublicTask) => {
const c = catBySlug.get(t.category)?.colour ?? '';
return /^#[0-9a-f]{3,8}$/i.test(c) ? c : '#737373';
};
const label = (t: PublicTask) =>
`${t.title}, ${SCALE_INFO[t.scale].label.toLowerCase()}, ${STATUS_LABELS[t.status].toLowerCase()}`;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const svgCache = new Map<string, string>();
function iconSvg(key: string | undefined) {
const k = key ?? '';
let svg = svgCache.get(k);
if (svg === undefined) {
const host = document.createElement('div');
const c = mount(categoryIcon(key) as unknown as Component<Record<string, unknown>>, {
target: host,
props: { size: 14, strokeWidth: 2.5, 'aria-hidden': 'true' }
});
svg = host.innerHTML.replace(/<!--[^>]*-->/g, '');
unmount(c);
svgCache.set(k, svg);
}
return svg;
}
const CHECK =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
function pinHtml(t: PublicTask) {
const inner =
t.status === 'done'
? CHECK
: PIN[t.scale] >= 20
? iconSvg(catBySlug.get(t.category)?.icon)
: '';
return `<span class="wt-pin ${t.status} ${t.locationPrecision}" style="--c:${colourOf(t)};--s:${PIN[t.scale]}px">${inner}</span>`;
}
function popupHtml(t: PublicTask) {
const cat = catBySlug.get(t.category);
const href = `${BASE}/${encodeURIComponent(t.slug)}`;
const change = `${BASE}/request?kind=edit&task=${encodeURIComponent(t.slug)}`;
return `<div class="wt-pop">
<div class="wt-chips">
<span class="wt-chip">${esc(SCALE_INFO[t.scale].label)}</span>
<span class="wt-chip"><i style="background:${colourOf(t)}"></i>${esc(cat?.label ?? t.category)}</span>
<span class="wt-status ${t.status}">${esc(STATUS_LABELS[t.status])}</span>
</div>
<a class="wt-title" href="${href}">${esc(t.title)}</a>
<p class="wt-where">${esc(t.locationName)}${t.locationPrecision === 'approximate' ? ' · <em>approximate location</em>' : ''}</p>
<p class="wt-desc">${esc(t.description)}</p>
<div class="wt-actions">
<a class="wt-btn primary" href="${href}">View task</a>
<a class="wt-btn" href="${change}">Request change</a>
</div>
</div>`;
}
function clusterPopupHtml(members: PublicTask[]) {
const items = members
.map(
(t) =>
`<li><i style="background:${colourOf(t)}"></i><a href="${BASE}/${encodeURIComponent(t.slug)}">${esc(t.title)}</a><span class="wt-status ${t.status}">${esc(STATUS_LABELS[t.status])}</span></li>`
)
.join('');
return `<div class="wt-pop"><p class="wt-where">${members.length} tasks here</p><ul class="wt-list">${items}</ul></div>`;
}
const popupOptions = () => ({
className: 'wt-popup',
maxWidth: Math.min(300, (el?.clientWidth ?? 320) - 48),
minWidth: Math.min(220, (el?.clientWidth ?? 320) - 64),
autoPanPadding: [16, 16] as [number, number]
});
function render() {
if (!map || !L || !layer) return;
const Lf = L;
const m = map;
layer.clearLayers();
pins.clear();
shownBy.clear();
clusterOf.clear();
const z = m.getZoom();
type P = { t: PublicTask; x: number; y: number; weight: number; at: Leaflet.LatLng };
const all: P[] = located.map((t) => {
const at = Lf.latLng(t.lat!, t.lon!);
const p = m.project(at, z);
return { t, x: p.x, y: p.y, weight: PIN[t.scale], at };
});
// From SPREAD_FROM, tasks sharing one coordinate (country centroids) fan out in a ring
// and never cluster; from UNCLUSTER_FROM nothing clusters.
const fanned: P[][] = [];
let rest = all;
if (z >= SPREAD_FROM) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const byCoord = new Map<string, P[]>();
for (const p of all) {
const k = coordKey(p.t.lat!, p.t.lon!);
byCoord.set(k, [...(byCoord.get(k) ?? []), p]);
}
for (const g of byCoord.values()) if (g.length > 1) fanned.push(g);
rest = all.filter((p) => !fanned.some((g) => g.includes(p)));
}
const groups: P[][] = [
...clusterPoints(rest, z >= UNCLUSTER_FROM ? 0 : z >= SPREAD_FROM ? 36 : 40),
...fanned.flatMap((g) => {
const ring = ringOffsets(g.length);
return g.map((p, i) => [
{ ...p, at: m.unproject([p.x + ring[i].dx, p.y + ring[i].dy], z) }
]);
})
];
for (const g of groups) {
if (g.length === 1) {
const { t, at } = g[0];
const s = PIN[t.scale];
const marker = Lf.marker(at, {
icon: Lf.divIcon({
className: 'wt-pin-wrap',
html: pinHtml(t),
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
popupAnchor: [0, -s / 2]
}),
keyboard: true,
riseOnHover: true,
zIndexOffset: s * 10
});
marker.bindPopup(popupHtml(t), popupOptions());
marker.on('add', () => {
const e = marker.getElement();
if (!e) return;
e.setAttribute('role', 'button');
e.setAttribute('aria-label', label(t));
e.removeAttribute('title');
shownBy.set(t.slug, e);
if (t.slug === highlight) e.classList.add('wt-hl');
});
pins.set(t.slug, marker);
layer.addLayer(marker);
continue;
}
const members = g.map((p) => p.t);
const bounds = Lf.latLngBounds(g.map((p) => p.at));
const center = bounds.getCenter();
const colours = [...new Set(members.map(colourOf))].slice(0, 8);
const step = 100 / colours.length;
const ring = colours.map((c, i) => `${c} ${i * step}% ${(i + 1) * step}%`).join(', ');
const size = Math.min(52, 30 + members.length * 2);
const marker = Lf.marker(center, {
icon: Lf.divIcon({
className: 'wt-pin-wrap',
html: `<span class="wt-cluster" style="--s:${size}px;background:conic-gradient(${ring})"><span>${members.length}</span></span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
}),
keyboard: true,
zIndexOffset: 1000
});
const entry = { at: center, members };
marker.on('click', () => {
const sameSpot = bounds.getNorthEast().distanceTo(bounds.getSouthWest()) < 200;
if (sameSpot || m.getZoom() >= UNCLUSTER_FROM)
Lf.popup(popupOptions())
.setLatLng(center)
.setContent(clusterPopupHtml(members))
.openOn(m);
else m.flyToBounds(bounds, { padding: [56, 56], maxZoom: UNCLUSTER_FROM, duration: 0.6 });
});
marker.on('add', () => {
const e = marker.getElement();
if (!e) return;
e.setAttribute('role', 'button');
e.setAttribute('aria-label', `${members.length} tasks here, zoom in`);
e.removeAttribute('title');
for (const t of members) shownBy.set(t.slug, e);
if (highlight && members.some((t) => t.slug === highlight)) e.classList.add('wt-hl');
});
for (const t of members) clusterOf.set(t.slug, entry);
layer.addLayer(marker);
}
}
function fit() {
if (!map || !L) return;
const pts = located.map((t) => [t.lat!, t.lon!] as [number, number]);
if (!pts.length) map.setView([25, 10], 2);
else if (pts.length === 1) map.setView(pts[0], FOCUS_ZOOM[located[0].scale]);
else map.fitBounds(L.latLngBounds(pts), { padding: [36, 36], maxZoom: 12 });
}
/** Fly to a task's pin and open its popup (list card click in map view). */
export function focusTask(slug: string) {
const t = located.find((x) => x.slug === slug);
if (!t || !map) return;
const m = map;
const r = el.getBoundingClientRect();
if (r.top < 0 || r.bottom > window.innerHeight)
el.scrollIntoView({
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center'
});
const zoom = Math.max(m.getZoom(), FOCUS_ZOOM[t.scale]);
m.once('moveend', () => {
render();
const pin = pins.get(slug);
if (pin) pin.openPopup();
else {
const c = clusterOf.get(slug);
if (c && L)
L.popup(popupOptions()).setLatLng(c.at).setContent(clusterPopupHtml(c.members)).openOn(m);
}
});
m.flyTo([t.lat!, t.lon!], zoom, { duration: 0.8 });
}
$effect(() => {
let disposed = false;
let stopTiles = () => {};
loadLeaflet()
.then((Lf) => {
if (disposed) return;
L = Lf;
const m = Lf.map(el, {
worldCopyJump: true,
scrollWheelZoom: false,
minZoom: 1,
maxZoom: 18,
zoomSnap: 0.5
});
map = m;
stopTiles = addThemedTiles(Lf, m);
layer = Lf.layerGroup().addTo(m);
// Scroll zoom only after the map is clicked or focused, so the page keeps scrolling.
const on = () => {
m.scrollWheelZoom.enable();
scrollOn = true;
};
const off = () => {
m.scrollWheelZoom.disable();
scrollOn = false;
};
m.on('click focus', on);
m.on('blur', off);
el.addEventListener('mouseleave', off);
m.on('zoomend', render);
untrack(fit);
render();
ready = true;
})
.catch(() => (failed = true));
return () => {
disposed = true;
stopTiles();
map?.remove();
map = null;
layer = null;
};
});
// Live updates and filter changes re-render the pins; only filter changes refit the view.
$effect(() => {
void located;
void catBySlug;
if (ready) untrack(render);
});
let lastFit = untrack(() => fitKey);
$effect(() => {
const k = fitKey;
if (!ready || k === lastFit) return;
lastFit = k;
untrack(fit);
});
$effect(() => {
const h = highlight;
if (!ready) return;
untrack(() => {
for (const e of el.querySelectorAll('.wt-hl')) e.classList.remove('wt-hl');
if (h) shownBy.get(h)?.classList.add('wt-hl');
});
});
</script>
<div class="wt-map-wrap">
<div
class="wt-map"
bind:this={el}
role="region"
aria-label="Map of tasks. Each pin is a button; the list below has every task too."
></div>
{#if failed}
<p class="overlay">The map could not load. The list below has every task.</p>
{:else if ready && !scrollOn}
<span class="hint" aria-hidden="true">Click the map to zoom with scroll</span>
{/if}
<details class="legend">
<summary>Legend</summary>
<div class="legend-body">
<ul aria-label="Categories">
{#each usedCats as c (c.slug)}
<li><i class="dot" style="background:{c.colour}"></i>{c.label}</li>
{/each}
</ul>
<ul aria-label="Scales" class="scales">
{#each WORLDTODO_SCALES.filter((s) => s !== 'planet') as s (s)}
<li>
<i class="dot size" style="--s:{PIN[s]}px"></i>{SCALE_INFO[s].label}
</li>
{/each}
</ul>
<ul aria-label="Pin styles">
<li><i class="dot ring"></i>In progress</li>
<li><i class="dot done"></i>Done</li>
<li><i class="dot approx"></i>Approximate (region, country)</li>
</ul>
</div>
</details>
</div>
<style>
/* Keyless fallback (OSM tiles) darkened to sit in the dark theme. */
:global(.wt-tiles-dim) {
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6);
}
.wt-map-wrap {
position: relative;
border: 1px solid var(--site-border);
border-radius: 0.85rem;
overflow: hidden;
background: var(--site-surface);
}
.wt-map {
height: min(70vh, 720px);
min-height: 320px;
background: #0e0e0e;
z-index: 0;
}
:global(html:not(.dark)) .wt-map {
background: #e9e9e9;
}
.hint,
.overlay {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.72rem;
color: var(--site-muted);
background: color-mix(in srgb, var(--site-bg) 85%, transparent);
border: 1px solid var(--site-border);
pointer-events: none;
}
.overlay {
inset: 40% 1rem auto;
text-align: center;
}
.legend {
position: absolute;
left: 0.6rem;
bottom: 1.6rem;
z-index: 500;
max-width: calc(100% - 1.2rem);
padding: 0.35rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: color-mix(in srgb, var(--site-bg) 90%, transparent);
backdrop-filter: blur(6px);
font-size: 0.75rem;
color: var(--site-text);
}
.legend summary {
cursor: pointer;
font-weight: 600;
}
.legend-body {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.2rem;
padding: 0.4rem 0 0.15rem;
max-height: 40vh;
overflow: auto;
}
.legend ul {
display: grid;
gap: 0.2rem;
align-content: start;
}
.legend li {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--site-muted);
white-space: nowrap;
}
.dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 999px;
border: 1.5px solid #fff;
flex: none;
}
.dot.size {
width: calc(var(--s) * 0.6);
height: calc(var(--s) * 0.6);
background: var(--site-muted);
}
.dot.ring {
background: #737373;
box-shadow: 0 0 0 2px #facc15;
}
.dot.done {
background: #525252;
border-color: #a3a3a3;
}
.dot.approx {
background: #737373;
outline: 1.5px dashed #a3a3a3;
outline-offset: 2px;
}
/* Leaflet-made DOM (pins, clusters, popups) lives inside .wt-map. */
.wt-map :global(.wt-pin-wrap) {
background: none;
border: 0;
border-radius: 999px;
}
.wt-map :global(.wt-pin-wrap:focus-visible) {
outline: 2px solid #fff;
outline-offset: 3px;
}
.wt-map :global(.wt-pin) {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: var(--s);
height: var(--s);
border-radius: 999px;
background: var(--c);
border: 2px solid #fff;
color: #fff;
box-shadow: 0 2px 8px rgb(0 0 0 / 0.55);
cursor: pointer;
transition: transform 0.15s;
}
.wt-map :global(.wt-pin svg) {
width: 62%;
height: 62%;
}
.wt-map :global(.wt-pin.approximate) {
outline: 2px dashed color-mix(in srgb, var(--c) 60%, #fff);
outline-offset: 3px;
}
.wt-map :global(.wt-pin.done) {
background: #4b4b4b;
border-color: #a3a3a3;
color: #e5e5e5;
opacity: 0.85;
}
.wt-map :global(.wt-pin.in_progress::after) {
content: '';
position: absolute;
inset: -5px;
border-radius: 999px;
border: 2px solid var(--c);
animation: wt-pulse 1.8s ease-out infinite;
pointer-events: none;
}
@keyframes -global-wt-pulse {
from {
transform: scale(0.85);
opacity: 0.95;
}
to {
transform: scale(1.7);
opacity: 0;
}
}
.wt-map :global(.wt-pin-wrap:hover .wt-pin),
.wt-map :global(.wt-pin-wrap:focus-visible .wt-pin),
.wt-map :global(.wt-hl .wt-pin),
.wt-map :global(.wt-pin-wrap:hover .wt-cluster),
.wt-map :global(.wt-hl .wt-cluster) {
transform: scale(1.35);
}
.wt-map :global(.wt-pin-wrap.wt-hl) {
z-index: 10000 !important;
}
.wt-map :global(.wt-hl .wt-pin) {
box-shadow:
0 0 0 3px #fff,
0 2px 12px rgb(0 0 0 / 0.6);
}
.wt-map :global(.wt-cluster) {
box-sizing: border-box;
display: flex;
width: var(--s);
height: var(--s);
padding: 3px;
border-radius: 999px;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.55);
cursor: pointer;
transition: transform 0.15s;
}
.wt-map :global(.wt-cluster span) {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #0a0a0a;
color: #fff;
font-size: 0.78rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.wt-map :global(.leaflet-control-attribution) {
background: color-mix(in srgb, var(--site-bg) 80%, transparent);
color: var(--site-muted);
font-size: 0.65rem;
}
.wt-map :global(.leaflet-control-attribution a) {
color: var(--site-text);
}
.wt-map :global(.leaflet-control-zoom) {
border: 1px solid var(--site-border);
border-radius: 0.6rem;
overflow: hidden;
}
.wt-map :global(.leaflet-control-zoom a) {
background: var(--site-bg);
color: var(--site-text);
border-color: var(--site-border);
}
.wt-map :global(.wt-popup .leaflet-popup-content-wrapper),
.wt-map :global(.wt-popup .leaflet-popup-tip) {
background: var(--site-surface);
color: var(--site-text);
border: 1px solid var(--site-border);
box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.7);
}
.wt-map :global(.wt-popup .leaflet-popup-content-wrapper) {
border-radius: 0.85rem;
}
.wt-map :global(.wt-popup .leaflet-popup-content) {
margin: 0.8rem 0.9rem;
font: inherit;
}
.wt-map :global(.wt-popup a.leaflet-popup-close-button) {
color: var(--site-muted);
}
.wt-map :global(.wt-pop) {
display: grid;
gap: 0.4rem;
font-size: 0.8rem;
}
.wt-map :global(.wt-chips) {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding-right: 1rem;
}
.wt-map :global(.wt-chip),
.wt-map :global(.wt-status) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.05rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.7rem;
color: var(--site-muted);
white-space: nowrap;
}
.wt-map :global(.wt-chip i),
.wt-map :global(.wt-list i) {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
flex: none;
}
.wt-map :global(.wt-status) {
color: var(--site-text);
}
.wt-map :global(.wt-status.done) {
background: var(--site-text);
color: var(--site-bg);
}
.wt-map :global(.wt-title) {
color: var(--site-text);
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
text-decoration: none;
}
.wt-map :global(.wt-title:hover) {
text-decoration: underline;
}
.wt-map :global(.wt-pop p) {
margin: 0;
}
.wt-map :global(.wt-where) {
color: var(--site-muted);
font-size: 0.75rem;
}
.wt-map :global(.wt-desc) {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--site-text);
line-height: 1.4;
}
.wt-map :global(.wt-actions) {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.15rem;
}
.wt-map :global(.wt-btn) {
padding: 0.3rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-text);
font-size: 0.75rem;
font-weight: 550;
text-decoration: none;
}
.wt-map :global(.wt-btn:hover),
.wt-map :global(.wt-btn:focus-visible) {
border-color: var(--site-text);
}
.wt-map :global(.wt-btn.primary) {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.wt-map :global(.wt-list) {
display: grid;
gap: 0.35rem;
max-height: 14rem;
overflow: auto;
margin: 0;
padding: 0;
list-style: none;
}
.wt-map :global(.wt-list li) {
display: flex;
align-items: center;
gap: 0.4rem;
}
.wt-map :global(.wt-list a) {
flex: 1;
color: var(--site-text);
text-decoration: none;
line-height: 1.3;
}
.wt-map :global(.wt-list a:hover) {
text-decoration: underline;
}
@media (max-width: 640px) {
.wt-map {
height: 60vh;
}
.hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.wt-map :global(.wt-pin.in_progress::after) {
animation: none;
transform: scale(1.25);
}
.wt-map :global(.wt-pin),
.wt-map :global(.wt-cluster) {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { clusterPoints, coordKey, ringOffsets } from './cluster';
const pt = (x: number, y: number, weight = 1) => ({ x, y, weight });
describe('clusterPoints', () => {
it('groups points within the radius and keeps far ones apart', () => {
const a = pt(0, 0);
const b = pt(10, 10);
const c = pt(200, 0);
const groups = clusterPoints([a, b, c], 40);
expect(groups).toHaveLength(2);
expect(groups.find((g) => g.includes(a))).toContain(b);
expect(groups.find((g) => g.includes(c))).toEqual([c]);
});
it('lets the heaviest point anchor the cluster', () => {
const small = pt(0, 0, 1);
const big = pt(30, 0, 5);
const far = pt(65, 0, 1);
const groups = clusterPoints([small, big, far], 40);
expect(groups[0][0]).toBe(big);
expect(groups[0]).toHaveLength(3);
});
it('radius 0 keeps every point alone', () => {
expect(clusterPoints([pt(0, 0), pt(0, 0)], 0)).toHaveLength(2);
});
});
describe('ringOffsets', () => {
it('is a no-op for one pin and spreads several pins apart', () => {
expect(ringOffsets(1)).toEqual([{ dx: 0, dy: 0 }]);
const ring = ringOffsets(4);
expect(ring).toHaveLength(4);
const keys = new Set(ring.map((o) => `${o.dx},${o.dy}`));
expect(keys.size).toBe(4);
});
it('rounds coordinates for grouping', () => {
expect(coordKey(52.2, 5.3)).toBe(coordKey(52.20001, 5.30002));
});
});

View File

@@ -0,0 +1,45 @@
// Tiny screen-space clustering for the task map (a few hundred pins at most, so O(n²) is fine
// and avoids a plugin). Points are in projected pixels at the current zoom.
export type ScreenPoint = { x: number; y: number; weight: number };
/**
* Greedy clustering: the heaviest unassigned point (largest scale) takes every unassigned
* point within `radius` px. Returns groups in that order; radius 0 keeps every point alone.
*/
export function clusterPoints<T extends ScreenPoint>(points: T[], radius: number): T[][] {
const order = [...points].sort((a, b) => b.weight - a.weight);
const used = new Set<T>();
const groups: T[][] = [];
const r2 = radius * radius;
for (const p of order) {
if (used.has(p)) continue;
used.add(p);
const group = [p];
if (radius > 0)
for (const q of order) {
if (used.has(q)) continue;
const dx = q.x - p.x;
const dy = q.y - p.y;
if (dx * dx + dy * dy <= r2) {
used.add(q);
group.push(q);
}
}
groups.push(group);
}
return groups;
}
/** Offsets (px) that fan out `n` pins sharing one coordinate in a small ring. */
export function ringOffsets(n: number): { dx: number; dy: number }[] {
if (n <= 1) return [{ dx: 0, dy: 0 }];
const r = 16 + n * 3;
return Array.from({ length: n }, (_, i) => {
const a = (2 * Math.PI * i) / n - Math.PI / 2;
return { dx: Math.round(Math.cos(a) * r), dy: Math.round(Math.sin(a) * r) };
});
}
/** Rounded lat/lon key, so tasks on the same centroid can be fanned out. */
export const coordKey = (lat: number, lon: number) => `${lat.toFixed(4)},${lon.toFixed(4)}`;

View File

@@ -8,6 +8,19 @@ export const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
export type RequestKind = (typeof WORLDTODO_REQUEST_KINDS)[number];
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
export type RequestStatus = (typeof WORLDTODO_REQUEST_STATUSES)[number];
export const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
export type LocationPrecision = (typeof WORLDTODO_LOCATION_PRECISIONS)[number];
/** Same rule as the tasks_location_precision trigger: what kind of pin a task gets. */
export function precisionFor(
scale: Scale,
lat: number | null | undefined,
lon: number | null | undefined
): LocationPrecision {
if (lat === null || lat === undefined || lon === null || lon === undefined) return 'none';
if (scale === 'planet') return 'none';
return scale === 'street' || scale === 'city' ? 'exact' : 'approximate';
}
/** The only keys a request payload may carry (checked server-side). */
export const EDITABLE_FIELDS = [
@@ -66,6 +79,8 @@ export type PublicTask = {
countryCode: string | null;
lat: number | null;
lon: number | null;
/** Map pin kind (see precisionFor). */
locationPrecision: LocationPrecision;
status: Status;
sourceUrls: string[];
sourceDate: string | null;

View File

@@ -0,0 +1,54 @@
import type * as Leaflet from 'leaflet';
import { PUBLIC_MAPTILER_API_KEY } from '$app/env/public';
// MapTiler "Dataviz" basemaps on OpenStreetMap data (the site's existing public MapTiler
// key): dark in the dark theme, light in the light one, swapped live when the theme
// toggles. Without a key (local dev) it falls back to the standard OSM tiles, darkened
// with a CSS filter in the dark theme. CARTO's raster tiles need a key since Sept 2026.
const isDark = () => document.documentElement.classList.contains('dark');
const OSM =
'&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors';
const MAPTILER =
'&copy; <a href="https://www.maptiler.com/copyright/" target="_blank" rel="noopener">MapTiler</a>';
function source(dark: boolean): { url: string; attribution: string; filtered: boolean } {
if (PUBLIC_MAPTILER_API_KEY) {
const style = dark ? 'dataviz-dark' : 'dataviz';
return {
url: `https://api.maptiler.com/maps/${style}/256/{z}/{x}/{y}{r}.png?key=${encodeURIComponent(PUBLIC_MAPTILER_API_KEY)}`,
attribution: `${MAPTILER} ${OSM}`,
filtered: false
};
}
return {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: OSM,
filtered: dark
};
}
/** Adds the themed tile layer to `map`; returns a cleanup for the theme observer. */
export function addThemedTiles(L: typeof Leaflet, map: Leaflet.Map): () => void {
const first = source(isDark());
const layer = L.tileLayer(first.url, {
maxZoom: 19,
attribution: first.attribution,
className: first.filtered ? 'wt-tiles-dim' : ''
}).addTo(map);
const apply = () => {
const s = source(isDark());
layer.getContainer()?.classList.toggle('wt-tiles-dim', s.filtered);
layer.setUrl(s.url);
};
const observer = new MutationObserver(apply);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
/** Leaflet is browser-only: load it on demand (never during SSR). */
export async function loadLeaflet(): Promise<typeof Leaflet> {
const mod = await import('leaflet');
return (mod.default ?? mod) as typeof Leaflet;
}

View File

@@ -1,26 +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 () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
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`),
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
streams(),
searchSkins(q, kind, sort)
]);
return {
stats: stats[0] as {
items: number;
observations: string;
earliest: Date | null;
latest: Date | null;
},
stats,
dimensions: dimensions as unknown as { key: string; values: number }[],
markets: markets as unknown as { source: string; currency: string; kind: string }[]
markets,
kinds: kinds.map(([value, label]) => ({ value, label })),
browse: { q, kind, sort, skins }
};
};

View File

@@ -9,6 +9,70 @@
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(`/projects/cs-prices/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;
@@ -40,15 +104,12 @@
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('steam-market|USD');
let kind = $state('sale-median');
let sourceChoice = $state('skinport|EUR');
let kind = $state('lowest-listing');
let markets = $derived(
data.markets.length
? data.markets
: [
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
]
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
@@ -90,7 +151,7 @@
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'sale-median';
'lowest-listing';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
@@ -115,8 +176,8 @@
}
const to = new SvelteDate();
const from =
range === 'all' && data.stats.earliest
? new SvelteDate(data.stats.earliest)
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);
@@ -124,10 +185,12 @@
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) from.setUTCFullYear(from.getUTCFullYear() - 1);
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`;
}
@@ -256,19 +319,73 @@
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
Explore price histories for individual listings or compare groups of items over time.
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.items)}</strong><span>listings indexed</span></div>
<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(Number(data.stats.observations))}</strong><span
>stored observations</span
<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={`/projects/cs-prices/${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">
@@ -357,11 +474,10 @@
></label
><label
>Range<NativeSelect bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><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
><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
>
@@ -394,9 +510,9 @@
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
Group lines average each listing equally within a bucket; raw observations remain in the
database.
)} · {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>
@@ -436,6 +552,95 @@
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;
@@ -637,6 +842,15 @@
}
}
@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;
}

View File

@@ -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
};
};

View File

@@ -0,0 +1,649 @@
<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'
};
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'
};
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="/projects/cs-prices">← 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>

View File

@@ -5,12 +5,14 @@ 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 : 'sale-median';
const source = typeof body.source === 'string' ? body.source : 'steam-market';
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
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 (
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
)
return Response.json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
return Response.json({ series: [], bucketSeconds: 0 });
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 });
// Aim for at most 240 buckets per series. Raw observations remain untouched.
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
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(
@@ -44,22 +46,22 @@ export const POST: RequestHandler = async ({ request }) => {
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 per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id,
date_bin(make_interval(secs => ${seconds}), p.observed_at,
TIMESTAMPTZ '1970-01-01') 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.price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz
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, round(avg(price_cents))::int AS price_cents,
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`);

View File

@@ -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 });
};

View File

@@ -18,18 +18,20 @@ export const load: PageServerLoad = async ({ url, parent }) => {
const status = STATUSES.includes(first('status')) ? first('status') : 'todo';
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
const q = first('q').slice(0, 100);
const view = first('view') === 'map' ? 'map' : 'list';
const filter = { scale, category, status, q };
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
return {
filter,
sort,
view,
tasks: list.tasks,
counts: totals,
seo: {
title: 'The worldwide todo list',
description:
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.',
index: !scale && !category && status === 'todo' && !q && sort === 'updated'
index: !scale && !category && status === 'todo' && !q && sort === 'updated' && view === 'list'
}
};
};

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
import WorldMap from '#lib/worldtodo/WorldMap.svelte';
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
import IconTile from '#lib/worldtodo/IconTile.svelte';
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
import { subscribe } from '#lib/worldtodo/live';
@@ -12,6 +14,9 @@
type PublicTask
} from '#lib/worldtodo/shared';
import Plus from 'lucide-svelte/icons/plus';
import List from 'lucide-svelte/icons/list';
import MapIcon from 'lucide-svelte/icons/map';
import Earth from 'lucide-svelte/icons/earth';
import Search from 'lucide-svelte/icons/search';
let { data } = $props();
@@ -90,6 +95,31 @@
};
const f = $derived(data.filter);
const isMap = $derived(data.view === 'map');
// List / Map toggle keeps every filter in the URL (?view=map is shareable).
const viewHref = (view: 'list' | 'map') => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const p = new URLSearchParams();
if (f.scale) p.set('scale', f.scale);
if (f.category) p.set('category', f.category);
if (f.status !== 'todo') p.set('status', f.status);
if (f.q) p.set('q', f.q);
if (data.sort !== 'updated') p.set('sort', data.sort);
if (view === 'map') p.set('view', 'map');
const s = p.toString();
return `/projects/worldwide-todo${s ? `?${s}` : ''}`;
};
const pinnable = (t: PublicTask) => t.locationPrecision !== 'none';
const globalTasks = $derived(tasks.filter((t) => !pinnable(t)));
const mapListed = $derived(tasks.filter(pinnable));
const fitKey = $derived(JSON.stringify(data.filter));
let mapRef = $state<ReturnType<typeof WorldMap>>();
let hovered = $state<string | null>(null);
function cardClick(e: MouseEvent, task: PublicTask) {
if (!isMap || !mapRef || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return;
e.preventDefault();
mapRef.focusTask(task.slug);
}
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
</script>
@@ -185,16 +215,75 @@
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
{#if f.category}<input type="hidden" name="category" value={f.category} />{/if}
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/if}
{#if isMap}<input type="hidden" name="view" value="map" />{/if}
</form>
<div class="results-bar">
<p class="results" aria-live="polite">
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q}&nbsp;matching “{f.q}”{/if}
{#if isMap}· {mapListed.length} on the map{/if}
{#if f.scale || f.category || f.q || f.status !== 'todo'}
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
· <a
href={isMap ? '/projects/worldwide-todo?view=map' : '/projects/worldwide-todo'}
data-sveltekit-noscroll>clear filters</a
>
{/if}
</p>
<nav class="views" aria-label="View">
<a
href={viewHref('list')}
class={{ on: !isMap }}
aria-current={!isMap ? 'page' : undefined}
data-sveltekit-noscroll
data-sveltekit-keepfocus><List size={15} aria-hidden="true" />List</a
>
<a
href={viewHref('map')}
class={{ on: isMap }}
aria-current={isMap ? 'page' : undefined}
data-sveltekit-noscroll
data-sveltekit-keepfocus><MapIcon size={15} aria-hidden="true" />Map</a
>
</nav>
</div>
<p class="sr-only" aria-live="polite">{announce}</p>
{#if isMap}
{#if globalTasks.length}
<section class="global" aria-label="Global tasks (no single place on the map)">
<h2><Earth size={15} aria-hidden="true" />Global <span>{globalTasks.length}</span></h2>
<ul>
{#each globalTasks as task (task.slug)}
{@const cat = catBySlug.get(task.category)}
<li>
<a
href="/projects/worldwide-todo/{task.slug}"
class={{ flash: flashing === task.slug }}
>
<span class="g-top">
<span class="badge">{task.scale === 'planet' ? 'Global' : 'No pin'}</span>
<span class="g-cat"
><i style="background:{cat?.colour ?? '#737373'}"></i>{cat?.label ??
task.category}</span
>
<StatusPill status={task.status} />
</span>
<span class="g-title">{task.title}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
<WorldMap
bind:this={mapRef}
tasks={mapListed}
categories={data.categories}
highlight={hovered}
{fitKey}
/>
{/if}
<ul class="grid">
<li>
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
@@ -203,8 +292,14 @@
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
</a>
</li>
{#each tasks as task (task.slug)}
<li>
{#each isMap ? mapListed : tasks as task (task.slug)}
<li
onmouseenter={() => (hovered = task.slug)}
onmouseleave={() => (hovered = hovered === task.slug ? null : hovered)}
onfocusin={() => (hovered = task.slug)}
onfocusout={() => (hovered = hovered === task.slug ? null : hovered)}
onclickcapture={(e) => cardClick(e, task)}
>
<TaskCard
{task}
category={catBySlug.get(task.category)}
@@ -379,6 +474,133 @@
font-size: 0.8rem;
color: var(--site-muted);
}
.results-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.views {
display: inline-flex;
gap: 0.35rem;
}
.views a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: var(--site-surface);
color: var(--site-muted);
font-size: 0.82rem;
font-weight: 550;
text-decoration: none;
transition:
border-color 0.15s,
color 0.15s,
transform 0.15s;
}
.views a:hover,
.views a:focus-visible {
color: var(--site-text);
border-color: var(--site-muted);
transform: translateY(-1px);
}
.views a.on {
color: var(--site-text);
border-color: var(--site-text);
box-shadow: inset 0 0 0 1px var(--site-text);
}
.global h2 {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
}
.global h2 :global(svg) {
color: #38bdf8;
}
.global h2 span {
color: var(--site-muted);
font-weight: 450;
font-variant-numeric: tabular-nums;
}
.global ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, 18rem);
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.35rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
}
.global li {
scroll-snap-align: start;
}
.global a {
display: flex;
flex-direction: column;
gap: 0.35rem;
height: 100%;
padding: 0.6rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
background-color 0.6s;
}
.global a:hover,
.global a:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.global a.flash {
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
}
.g-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.badge {
padding: 0.05rem 0.45rem;
border-radius: 999px;
color: #fff;
font-weight: 600;
background: linear-gradient(135deg, #38bdf8, #16a34a);
}
.g-cat {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.g-cat i {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
}
.g-title {
font-size: 0.85rem;
font-weight: 550;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.results a {
color: var(--site-text);
}

View File

@@ -0,0 +1,45 @@
import { listTasks } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// GET /projects/worldwide-todo/api/tasks.geojson?scale=&category=&status=&q=
// Every task with a map position (precision exact or approximate) as a GeoJSON
// FeatureCollection, same filters as /api/tasks. Planet tasks and tasks without coordinates
// are left out (precision 'none').
export const GET: RequestHandler = async ({ url }) => {
const p = (k: string) => url.searchParams.get(k)?.trim() ?? '';
const scale = ['planet', 'region', 'country', 'city', 'street'].includes(p('scale'))
? p('scale')
: '';
const status = ['todo', 'open', 'in_progress', 'done', 'all'].includes(p('status'))
? p('status')
: 'todo';
const category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
const { tasks } = await listTasks(
{ scale, category, status, q: p('q').slice(0, 100) },
'updated',
1000
);
const features = tasks
.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
.map((t) => ({
type: 'Feature' as const,
id: t.id,
geometry: { type: 'Point' as const, coordinates: [t.lon!, t.lat!] },
properties: {
slug: t.slug,
title: t.title,
scale: t.scale,
category: t.category,
status: t.status,
location_name: t.locationName,
location_precision: t.locationPrecision,
url: `${url.origin}/projects/worldwide-todo/${t.slug}`
}
}));
return new Response(JSON.stringify({ type: 'FeatureCollection', features }), {
headers: {
'content-type': 'application/geo+json; charset=utf-8',
'cache-control': 'public, max-age=30'
}
});
};

View File

@@ -60,6 +60,16 @@
new: formatValue(k, change[k])
}));
}
/** Coordinates the request proposes (picked on the map or typed), to check before approving. */
function proposedPosition(item: Item) {
const p = item.payload as Record<string, unknown>;
if (item.kind === 'delete' || !('lat' in p || 'lon' in p)) return null;
const lat = Number(p.lat ?? item.task?.lat);
const lon = Number(p.lon ?? item.task?.lon);
return p.lat !== null && p.lon !== null && Number.isFinite(lat) && Number.isFinite(lon)
? { lat, lon }
: null;
}
function tweakValues(item: Item) {
const base = item.task ? fieldsOf(item.task) : {};
return { ...base, ...item.payload } as Record<string, unknown>;
@@ -194,6 +204,15 @@
{/each}
</tbody>
</table>
{@const pos = proposedPosition(item)}
{#if pos}
<a
class="osm"
href="https://www.openstreetmap.org/?mlat={pos.lat}&mlon={pos.lon}#map=15/{pos.lat}/{pos.lon}"
target="_blank"
rel="noopener noreferrer">Check the proposed map position on OpenStreetMap ↗</a
>
{/if}
{/if}
<dl class="meta">
@@ -573,4 +592,10 @@
transform: none;
}
}
.osm {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.78rem;
color: var(--site-text);
}
</style>

View File

@@ -20,6 +20,7 @@
import Sparkles from 'lucide-svelte/icons/sparkles';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import TrendingUp from 'lucide-svelte/icons/trending-up';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
@@ -202,6 +203,8 @@
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
// Auto metrics (goal.metricKey): stacked bar per part, colourful on the black/white base.
const PART_COLORS = ['rgb(14 165 233)', 'rgb(245 158 11)', 'rgb(168 85 247)', 'rgb(16 185 129)'];
const money2 = (n: number) =>
new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' }).format(n);
const partPct = (v: number, target: number) => Math.max(0, Math.min(100, (v / target) * 100));
const pctLabel = (v: number, target: number) => {
const p = (v / target) * 100;
@@ -521,9 +524,9 @@
aria-valuemax={target}
aria-valuenow={goal.metricCurrent}
>
{#each d.parts as p, i (p.key)}<span
{#each d.parts.filter((p) => p.value > 0) as p (p.key)}<span
style:width={`${partPct(p.value, target)}%`}
style:background={PART_COLORS[i % PART_COLORS.length]}
style:background={PART_COLORS[d.parts.indexOf(p) % PART_COLORS.length]}
title={`${p.label}: ${money(p.value, goal.metricUnit)}`}
></span>{/each}
</div>
@@ -531,12 +534,14 @@
{#each d.parts as p, i (p.key)}
<li>
<span class="dot" style:color={PART_COLORS[i % PART_COLORS.length]}
>{#if p.key === 'side-income'}<Sparkles size={12} />{:else}<Briefcase
>{#if p.key === 'side-income'}<Sparkles
size={12}
/>{/if}</span
/>{:else if p.key === 'investment-income'}<TrendingUp
size={12}
/>{:else}<Briefcase size={12} />{/if}</span
>
<span class="truncate" title={p.note ?? ''}>{p.label}</span>
<span class="amt">{money(p.value, goal.metricUnit)}</span>
<span class="amt">{money2(p.value)}</span>
</li>
{/each}
</ul>