Compare commits
12 Commits
money-dail
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23a43049a0 | |||
| 3b75fd39b5 | |||
| 76f063b362 | |||
| 7396d23335 | |||
| 22279d181f | |||
| 8d2da984e0 | |||
| 9e89a615c6 | |||
| 9d79dfb43a | |||
| 21b10ea894 | |||
| 78b69c7d8f | |||
| d3b9bb9ba2 | |||
| 0121071af2 |
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal file
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal 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 $$;
|
||||
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal file
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal 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 $$;
|
||||
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "ai"."provider_setup_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"provider_id" integer NOT NULL,
|
||||
"section" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"note" text,
|
||||
"scope" text,
|
||||
"link" text,
|
||||
"body" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_entries_section_check" CHECK ("section" IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')),
|
||||
CONSTRAINT "provider_setup_entries_scope_check" CHECK ("scope" IS NULL OR "scope" IN ('global','per-repo','app')),
|
||||
CONSTRAINT "provider_setup_entries_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai"."provider_setup_providers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"products" text,
|
||||
"color" text DEFAULT 'zinc' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_providers_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "provider_setup_providers_color_check" CHECK ("color" IN ('orange','green','blue','violet','pink','sky','amber','zinc'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "provider_setup_entries_provider_idx" ON "ai"."provider_setup_entries" ("provider_id","section","sort_order");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "provider_setup_providers_name_key" ON "ai"."provider_setup_providers" ("name");--> statement-breakpoint
|
||||
ALTER TABLE "ai"."provider_setup_entries" ADD CONSTRAINT "provider_setup_entries_JxI1C1Zk458j_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai"."provider_setup_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Seed the provider cards (owner edits them on /trackers/ai/setup). Entries are added there.
|
||||
INSERT INTO "ai"."provider_setup_providers" ("name", "products", "color", "sort_order") VALUES
|
||||
('Claude', 'claude.ai + Claude Code', 'orange', 10),
|
||||
('ChatGPT', 'ChatGPT + Codex', 'green', 20),
|
||||
('Gemini', 'Gemini + Gemini CLI', 'blue', 30),
|
||||
('Grok', 'Grok + Grok Bot', 'violet', 40),
|
||||
('Copilot', 'GitHub Copilot', 'sky', 50)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Same privacy as the rest of schema ai: app role only (default privileges already cover new
|
||||
-- tables; granted explicitly too). Grants only run on prod (migrations as `personal`).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON "ai"."provider_setup_providers", "ai"."provider_setup_entries" TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "cs"."history_fetches" (
|
||||
"item_id" integer,
|
||||
"source" text,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"points" integer DEFAULT 0 NOT NULL,
|
||||
"first_day" date,
|
||||
"error" text,
|
||||
CONSTRAINT "history_fetches_pkey" PRIMARY KEY("item_id","source")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cs"."history_fetches" ADD CONSTRAINT "history_fetches_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;
|
||||
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
399
scripts/cs-history.ts
Normal file
399
scripts/cs-history.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* CS2 price history backfill into cs.daily_prices, under separate `*-history` streams so it never
|
||||
* mixes with the daily scrape. Resumable (cs.history_fetches) and throttled; run in the background:
|
||||
*
|
||||
* bun scripts/cs-history.ts fx ECB USD→EUR for every day since 2013 (Frankfurter),
|
||||
* weekends/holidays carry the last rate forward
|
||||
* bun scripts/cs-history.ts steam [max] Steam Community Market daily median sale price +
|
||||
* volume since listing, read from the public market
|
||||
* listing page (no login). One page holds every wear /
|
||||
* StatTrak / Souvenir sibling, so it is ~1 request per
|
||||
* skin, paced ~9 s apart with backoff on 429.
|
||||
* bun scripts/cs-history.ts csfloat-queue print CSFloat targets (NDJSON) for an off-box fetcher
|
||||
* bun scripts/cs-history.ts csfloat-import read CSFloat graph NDJSON on stdin and store it
|
||||
* bun scripts/cs-history.ts status coverage per history stream
|
||||
*
|
||||
* Steam prices arrive in the page's wallet currency (EUR here, `ecurrency` 3) and are stored as
|
||||
* is; USD streams (CSFloat) are converted per day with cs.fx_rates on read.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
|
||||
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] ?? 'status';
|
||||
const ua = 'Mozilla/5.0';
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const log = (...args: unknown[]) => console.log(new Date().toISOString().slice(0, 19), ...args);
|
||||
|
||||
const streams = {
|
||||
steam: {
|
||||
source: 'steam-market',
|
||||
kind: 'sale-median-history',
|
||||
label: 'Steam Market daily median sale (history)'
|
||||
},
|
||||
csfloat: {
|
||||
source: 'csfloat',
|
||||
kind: 'sale-avg-history',
|
||||
label: 'CSFloat daily average sale (history)'
|
||||
}
|
||||
};
|
||||
async function streamId(source: string, currency: string, kind: string, label: string) {
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.price_streams (source, currency, kind, label)
|
||||
VALUES (${source}, ${currency}, ${kind}, ${label})
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label RETURNING id`;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
type Day = { day: string; cents: number; volume: number | null };
|
||||
async function store(stream: number, item: number, days: Day[]) {
|
||||
for (let offset = 0; offset < days.length; offset += 5000) {
|
||||
const chunk = days.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT ${item}::int, ${stream}::smallint, day, price_cents, volume
|
||||
FROM unnest(${chunk.map((d) => d.day)}::date[], ${chunk.map((d) => d.cents)}::int[],
|
||||
${chunk.map((d) => d.volume)}::int[]) AS x(day, price_cents, volume)
|
||||
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||
}
|
||||
}
|
||||
async function mark(item: number, source: string, days: Day[], error: string | null = null) {
|
||||
await sql`
|
||||
INSERT INTO cs.history_fetches (item_id, source, fetched_at, points, first_day, error)
|
||||
VALUES (${item}, ${source}, now(), ${days.length}, ${days[0]?.day ?? null}, ${error})
|
||||
ON CONFLICT (item_id, source) DO UPDATE SET fetched_at = now(), points = EXCLUDED.points,
|
||||
first_day = EXCLUDED.first_day, error = EXCLUDED.error`;
|
||||
}
|
||||
|
||||
async function fx() {
|
||||
const from = '2013-01-01';
|
||||
const to = new Date().toISOString().slice(0, 10);
|
||||
let rates: Record<string, { EUR: number }> | undefined;
|
||||
for (const url of [
|
||||
`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=EUR`,
|
||||
`https://api.frankfurter.app/${from}..${to}?from=USD&to=EUR`
|
||||
]) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (response.ok) {
|
||||
rates = ((await response.json()) as { rates: typeof rates }).rates;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rates) throw new Error('No FX history');
|
||||
const days: string[] = [];
|
||||
const values: number[] = [];
|
||||
let last: number | null = null;
|
||||
for (let t = Date.parse(from); t <= Date.parse(to); t += 86_400_000) {
|
||||
const day = new Date(t).toISOString().slice(0, 10);
|
||||
last = rates[day]?.EUR ?? last;
|
||||
if (last != null) {
|
||||
days.push(day);
|
||||
values.push(last);
|
||||
}
|
||||
}
|
||||
// Existing rows (the daily scrape's own rate) win.
|
||||
const result = await sql`
|
||||
INSERT INTO cs.fx_rates (day, usd_eur)
|
||||
SELECT * FROM unnest(${days}::date[], ${values}::numeric[]) ON CONFLICT (day) DO NOTHING`;
|
||||
log(
|
||||
`FX: ${Object.keys(rates).length} ECB fixings, ${result.count} days inserted (${days[0]}..${to})`
|
||||
);
|
||||
}
|
||||
|
||||
/** Every pricehistory query embedded in the listing page's SSR state. */
|
||||
function pageHistories(html: string) {
|
||||
const match = html.match(
|
||||
/<script type="application\/json" id="valve-ssr-data"[^>]*>([\s\S]*?)<\/script>/
|
||||
);
|
||||
if (!match) return null;
|
||||
const found = new Map<
|
||||
string,
|
||||
{ currency: number; prices: { time: number; price_median: number; purchases: number }[] }
|
||||
>();
|
||||
const walk = (node: unknown): void => {
|
||||
if (typeof node === 'string') {
|
||||
if (node.length > 2 && (node[0] === '{' || node[0] === '[')) {
|
||||
try {
|
||||
walk(JSON.parse(node));
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>;
|
||||
const key = record.queryKey;
|
||||
if (
|
||||
Array.isArray(key) &&
|
||||
key[0] === 'market' &&
|
||||
key[1] === 'pricehistory' &&
|
||||
key[2] === 730
|
||||
) {
|
||||
const data = (record.state as { data?: { ecurrency?: number; prices?: [] } } | undefined)
|
||||
?.data;
|
||||
if (data && Array.isArray(data.prices))
|
||||
found.set(String(key[3]), { currency: data.ecurrency ?? 0, prices: data.prices });
|
||||
}
|
||||
for (const value of Object.values(record)) walk(value);
|
||||
}
|
||||
};
|
||||
walk(JSON.parse(match[1]));
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Hourly points (last month) collapse to one row per UTC day, volume-weighted. */
|
||||
function daily(prices: { time: number; price_median: number; purchases: number }[]): Day[] {
|
||||
const byDay = new Map<string, { sum: number; weight: number; volume: number }>();
|
||||
for (const p of prices) {
|
||||
if (!Number.isFinite(p.price_median) || p.price_median <= 0) continue;
|
||||
const day = new Date(p.time * 1000).toISOString().slice(0, 10);
|
||||
const weight = Math.max(1, p.purchases || 0);
|
||||
const entry = byDay.get(day) ?? { sum: 0, weight: 0, volume: 0 };
|
||||
entry.sum += p.price_median * weight;
|
||||
entry.weight += weight;
|
||||
entry.volume += p.purchases || 0;
|
||||
byDay.set(day, entry);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, e]) => ({ day, cents: Math.round((e.sum / e.weight) * 100), volume: e.volume }))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000);
|
||||
}
|
||||
|
||||
async function curlPage(url: string) {
|
||||
const proc = Bun.spawn(
|
||||
['curl', '-sL', '--compressed', '--max-time', '60', '-A', ua, '-w', '\n%{http_code}', url],
|
||||
{ stdout: 'pipe', stderr: 'ignore' }
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const cut = out.lastIndexOf('\n');
|
||||
return { status: Number(out.slice(cut + 1)) || 0, body: out.slice(0, cut) };
|
||||
}
|
||||
|
||||
const kindOrder = [
|
||||
'knife',
|
||||
'gloves',
|
||||
'weapon',
|
||||
'container',
|
||||
'agent',
|
||||
'key',
|
||||
'music-kit',
|
||||
'patch',
|
||||
'collectible',
|
||||
'sticker',
|
||||
'graffiti',
|
||||
'charm',
|
||||
'other'
|
||||
];
|
||||
|
||||
async function steam(max: number) {
|
||||
const currencies: Record<number, string> = { 1: 'USD', 3: 'EUR' };
|
||||
const ids: Record<string, number> = {};
|
||||
const idFor = async (currency: string) =>
|
||||
(ids[currency] ??= await streamId(
|
||||
streams.steam.source,
|
||||
currency,
|
||||
streams.steam.kind,
|
||||
streams.steam.label
|
||||
));
|
||||
await idFor('EUR');
|
||||
// One representative unfetched phase-less config per skin, popular kinds/skins first.
|
||||
const queue = await sql<{ skin_id: string; item_id: number; hash: string }[]>`
|
||||
WITH todo AS (
|
||||
SELECT i.id, i.skin_id, i.market_hash_name, s.kind,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w
|
||||
WHERE w.item_id = i.id AND w.source = 'skinport' AND w.span = '90d'), 0) AS vol
|
||||
FROM cs.items i JOIN cs.skins s ON s.id = i.skin_id
|
||||
WHERE i.phase IS NULL AND NOT EXISTS (SELECT 1 FROM cs.history_fetches f
|
||||
WHERE f.item_id = i.id AND f.source = 'steam')
|
||||
), skins AS (
|
||||
SELECT skin_id, kind, sum(vol) AS vol FROM todo GROUP BY 1, 2
|
||||
)
|
||||
SELECT DISTINCT ON (s.vol, s.skin_id) s.skin_id, t.id AS item_id, t.market_hash_name AS hash
|
||||
FROM skins s JOIN todo t ON t.skin_id = s.skin_id
|
||||
ORDER BY s.vol DESC, s.skin_id, t.vol DESC, t.id`;
|
||||
const kinds = new Map(
|
||||
(await sql<{ id: string; kind: string }[]>`SELECT id, kind FROM cs.skins`).map((r) => [
|
||||
r.id,
|
||||
r.kind
|
||||
])
|
||||
);
|
||||
const rank = (skin: string) => {
|
||||
const index = kindOrder.indexOf(kinds.get(skin) ?? 'other');
|
||||
return index < 0 ? kindOrder.length : index;
|
||||
};
|
||||
const ordered = queue
|
||||
.map((row, index) => ({ ...row, index }))
|
||||
.sort((a, b) => rank(a.skin_id) - rank(b.skin_id) || a.index - b.index);
|
||||
log(`Steam: ${ordered.length} skins with unfetched configs`);
|
||||
let delay = 9000;
|
||||
let strikes = 0;
|
||||
let requests = 0;
|
||||
let stored = 0;
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const row = ordered[i];
|
||||
if (requests >= max) break;
|
||||
// Skip when an earlier page already covered this skin's representative.
|
||||
const [done] =
|
||||
await sql`SELECT 1 FROM cs.history_fetches WHERE item_id = ${row.item_id} AND source = 'steam'`;
|
||||
if (done) continue;
|
||||
const siblings = await sql<{ id: number; hash: string }[]>`
|
||||
SELECT id, market_hash_name AS hash FROM cs.items WHERE skin_id = ${row.skin_id} AND phase IS NULL`;
|
||||
const byHash = new Map(siblings.map((s) => [s.hash, s.id]));
|
||||
const url = `https://steamcommunity.com/market/listings/730/${encodeURIComponent(row.hash)}`;
|
||||
requests++;
|
||||
// curl with a bare UA and no Accept-Language: Steam answers Bun's fetch, and any request
|
||||
// with Accept-Language, with 429 but serves this normally.
|
||||
let page: { status: number; body: string };
|
||||
try {
|
||||
page = await curlPage(url);
|
||||
} catch (cause) {
|
||||
log(`net error ${row.hash}: ${String(cause)}`);
|
||||
await sleep(60_000);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
if (page.status === 429 || page.status >= 500 || page.status === 0) {
|
||||
strikes++;
|
||||
delay = Math.min(30_000, delay + 1000);
|
||||
const pause = Math.min(30 * 60_000, 60_000 * strikes);
|
||||
log(
|
||||
`HTTP ${page.status} (strike ${strikes}), pausing ${pause / 1000}s, delay now ${delay / 1000}s`
|
||||
);
|
||||
await sleep(pause);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
strikes = 0;
|
||||
if (page.status !== 200) {
|
||||
await mark(row.item_id, 'steam', [], `HTTP ${page.status}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
const histories = pageHistories(page.body);
|
||||
if (!histories) {
|
||||
await mark(row.item_id, 'steam', [], 'no ssr data');
|
||||
log(`no SSR data for ${row.hash}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
let pageRows = 0;
|
||||
for (const [hash, history] of histories) {
|
||||
const item =
|
||||
byHash.get(hash) ??
|
||||
(
|
||||
await sql<{ id: number }[]>`SELECT id FROM cs.items WHERE market_hash_name = ${hash}
|
||||
AND phase IS NULL`
|
||||
)[0]?.id;
|
||||
if (!item) continue;
|
||||
const currency = currencies[history.currency];
|
||||
if (!currency) {
|
||||
await mark(item, 'steam', [], `currency ${history.currency}`);
|
||||
continue;
|
||||
}
|
||||
const days = daily(history.prices);
|
||||
if (days.length) await store(await idFor(currency), item, days);
|
||||
await mark(item, 'steam', days);
|
||||
pageRows += days.length;
|
||||
}
|
||||
if (!histories.has(row.hash)) await mark(row.item_id, 'steam', [], 'not on page');
|
||||
stored += pageRows;
|
||||
if (requests % 25 === 0 || requests <= 3)
|
||||
log(
|
||||
`#${requests} ${row.hash}: ${histories.size} configs, ${pageRows} days (total ${stored})`
|
||||
);
|
||||
await sleep(delay + Math.random() * 3000);
|
||||
}
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${stored}
|
||||
WHERE source = ${streams.steam.source} AND kind = ${streams.steam.kind}`;
|
||||
log(`Steam done: ${requests} requests, ${stored} day rows`);
|
||||
}
|
||||
|
||||
/** Targets for the CSFloat fetcher: phase-less configs plus Doppler phases (paint index added by
|
||||
* the fetcher), most traded first, skipping ones already fetched. */
|
||||
async function csfloatQueue() {
|
||||
const rows = await sql<{ item_id: number; hash: string; phase: string | null; vol: number }[]>`
|
||||
SELECT i.id AS item_id, i.market_hash_name AS hash, i.phase,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w WHERE w.item_id = i.id
|
||||
AND w.span = '90d'), 0)::int AS vol
|
||||
FROM cs.items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cs.history_fetches f WHERE f.item_id = i.id AND f.source = 'csfloat')
|
||||
ORDER BY vol DESC, i.id`;
|
||||
for (const row of rows) process.stdout.write(JSON.stringify(row) + '\n');
|
||||
}
|
||||
|
||||
async function csfloatImport() {
|
||||
const stream = await streamId(
|
||||
streams.csfloat.source,
|
||||
'USD',
|
||||
streams.csfloat.kind,
|
||||
streams.csfloat.label
|
||||
);
|
||||
let items = 0;
|
||||
let rows = 0;
|
||||
let buffer = '';
|
||||
const handle = async (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
const entry = JSON.parse(line) as {
|
||||
item_id: number;
|
||||
error?: string;
|
||||
graph?: { day: string; avg_price: number; count: number }[];
|
||||
};
|
||||
const days = (entry.graph ?? [])
|
||||
.map((g) => ({
|
||||
day: g.day.slice(0, 10),
|
||||
cents: Math.round(g.avg_price),
|
||||
volume: g.count ?? null
|
||||
}))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000)
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
if (days.length) await store(stream, entry.item_id, days);
|
||||
await mark(entry.item_id, 'csfloat', days, entry.error ?? null);
|
||||
items++;
|
||||
rows += days.length;
|
||||
};
|
||||
for await (const chunk of Bun.stdin.stream()) {
|
||||
buffer += new TextDecoder().decode(chunk);
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) await handle(line);
|
||||
}
|
||||
await handle(buffer);
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${rows} WHERE id = ${stream}`;
|
||||
log(`CSFloat import: ${items} configs, ${rows} day rows`);
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const rows = await sql`
|
||||
SELECT st.source, st.kind, st.currency, count(*)::int AS rows, count(DISTINCT dp.item_id)::int AS configs,
|
||||
min(dp.day)::text AS first_day, max(dp.day)::text AS last_day
|
||||
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||
WHERE st.kind LIKE '%-history' GROUP BY 1, 2, 3 ORDER BY 1`;
|
||||
console.table(rows);
|
||||
const [cov] = await sql`
|
||||
WITH h AS (SELECT dp.item_id, count(DISTINCT dp.day) AS days FROM cs.daily_prices dp
|
||||
JOIN cs.price_streams st ON st.id = dp.stream_id WHERE st.kind LIKE '%-history' GROUP BY 1)
|
||||
SELECT (SELECT count(*) FROM cs.items)::int AS configs, count(*)::int AS with_history,
|
||||
count(*) FILTER (WHERE days > 30)::int AS over_30_days FROM h`;
|
||||
console.log(cov);
|
||||
console.table(
|
||||
await sql`SELECT source, count(*)::int AS fetched, sum(points)::int AS points,
|
||||
count(*) FILTER (WHERE points > 0)::int AS with_points, max(fetched_at) AS last
|
||||
FROM cs.history_fetches GROUP BY 1`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'fx') await fx();
|
||||
else if (mode === 'steam') await steam(Number(process.argv[3] ?? Infinity));
|
||||
else if (mode === 'csfloat-queue') await csfloatQueue();
|
||||
else if (mode === 'csfloat-import') await csfloatImport();
|
||||
else if (mode === 'status') await status();
|
||||
else throw new Error('Usage: cs-history.ts fx|steam [max]|csfloat-queue|csfloat-import|status');
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
624
scripts/cs-prices.ts
Normal file
624
scripts/cs-prices.ts
Normal 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¤cy=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¤cy=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();
|
||||
}
|
||||
@@ -188,8 +188,9 @@ try {
|
||||
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
|
||||
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
|
||||
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
|
||||
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
|
||||
const AI_TABLES = 3;
|
||||
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
|
||||
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
|
||||
const AI_TABLES = 5;
|
||||
const [ai] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 12,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
],
|
||||
tooltip:
|
||||
series.length > 100
|
||||
? { show: false }
|
||||
dataZoom: zoom
|
||||
? [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
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>
|
||||
|
||||
43
src/lib/cs-prices/catalog.test.ts
Normal file
43
src/lib/cs-prices/catalog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
src/lib/cs-prices/catalog.ts
Normal file
87
src/lib/cs-prices/catalog.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
>,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 ?? [])
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
217
src/lib/server/ai-setup.ts
Normal file
217
src/lib/server/ai-setup.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
|
||||
// each with entries grouped by section. Labels only: values that look like secrets are refused.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
AI_SETUP_COLORS,
|
||||
AI_SETUP_SCOPES,
|
||||
AI_SETUP_SECTIONS,
|
||||
aiSetupEntries,
|
||||
aiSetupProviders
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
|
||||
const SECRET =
|
||||
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
|
||||
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
|
||||
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
|
||||
|
||||
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
|
||||
const optional = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
const idField = z.coerce.number().int().positive();
|
||||
|
||||
const providerInput = z.object({
|
||||
name: trimmed(60),
|
||||
products: optional(120),
|
||||
color: z.enum(AI_SETUP_COLORS).catch('zinc')
|
||||
});
|
||||
const entryInput = z
|
||||
.object({
|
||||
section: z.enum(AI_SETUP_SECTIONS),
|
||||
name: trimmed(120),
|
||||
note: optional(400),
|
||||
scope: z
|
||||
.enum(['', ...AI_SETUP_SCOPES])
|
||||
.nullish()
|
||||
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
|
||||
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
|
||||
body: optional(20000)
|
||||
})
|
||||
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
|
||||
message: SECRET_MSG
|
||||
});
|
||||
|
||||
export type SetupEntry = {
|
||||
id: number;
|
||||
section: (typeof AI_SETUP_SECTIONS)[number];
|
||||
name: string;
|
||||
note: string | null;
|
||||
scope: string | null;
|
||||
link: string | null;
|
||||
body: string | null;
|
||||
bodyHtml: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SetupProvider = {
|
||||
id: number;
|
||||
name: string;
|
||||
products: string | null;
|
||||
color: (typeof AI_SETUP_COLORS)[number];
|
||||
updatedAt: string;
|
||||
entries: SetupEntry[];
|
||||
};
|
||||
|
||||
export async function loadSetup(): Promise<SetupProvider[]> {
|
||||
const providers = await db
|
||||
.select()
|
||||
.from(aiSetupProviders)
|
||||
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(aiSetupEntries)
|
||||
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
|
||||
return providers.map((p) => {
|
||||
const own = entries.filter((e) => e.providerId === p.id);
|
||||
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
products: p.products,
|
||||
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
|
||||
? (p.color as SetupProvider['color'])
|
||||
: 'zinc',
|
||||
updatedAt: new Date(latest).toISOString(),
|
||||
entries: own.map((e) => ({
|
||||
id: e.id,
|
||||
section: e.section as SetupEntry['section'],
|
||||
name: e.name,
|
||||
note: e.note,
|
||||
scope: e.scope,
|
||||
link: e.link,
|
||||
body: e.body,
|
||||
bodyHtml: e.body ? renderBody(e.body) : null,
|
||||
updatedAt: e.updatedAt.toISOString()
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type Result = { ok: true } | { ok: false; message: string };
|
||||
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
|
||||
const fields = (form: FormData) =>
|
||||
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||
|
||||
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
|
||||
const touch = (providerId: number) =>
|
||||
db
|
||||
.update(aiSetupProviders)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(aiSetupProviders.id, providerId));
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
|
||||
|
||||
export async function addProvider(form: FormData): Promise<Result> {
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupProviders);
|
||||
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function addEntry(form: FormData): Promise<Result> {
|
||||
const providerId = idField.safeParse(form.get('provider_id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!providerId.success) return { ok: false, message: 'Missing provider' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.providerId, providerId.data));
|
||||
await db
|
||||
.insert(aiSetupEntries)
|
||||
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
|
||||
await touch(providerId.data);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [row] = await db
|
||||
.update(aiSetupEntries)
|
||||
.set(parsed.data)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
const [row] = await db
|
||||
.delete(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
275
src/lib/server/cs-prices.ts
Normal 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 };
|
||||
@@ -1,5 +1,13 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
check,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { aiSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
|
||||
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
|
||||
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
|
||||
* like a secret, and nothing here is ever a key value.
|
||||
*/
|
||||
export const AI_SETUP_SECTIONS = [
|
||||
'connections',
|
||||
'skills',
|
||||
'instructions',
|
||||
'plugins',
|
||||
'routines',
|
||||
'agents',
|
||||
'plan',
|
||||
'notes'
|
||||
] as const;
|
||||
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
|
||||
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
|
||||
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
|
||||
export const AI_SETUP_COLORS = [
|
||||
'orange',
|
||||
'green',
|
||||
'blue',
|
||||
'violet',
|
||||
'pink',
|
||||
'sky',
|
||||
'amber',
|
||||
'zinc'
|
||||
] as const;
|
||||
|
||||
export const aiSetupProviders = aiSchema.table(
|
||||
'provider_setup_providers',
|
||||
{
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
/** The products this card covers, e.g. "claude.ai + Claude Code". */
|
||||
products: text('products'),
|
||||
color: text('color').notNull().default('zinc'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('provider_setup_providers_name_key').on(t.name),
|
||||
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||
check(
|
||||
'provider_setup_providers_color_check',
|
||||
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiSetupEntries = aiSchema.table(
|
||||
'provider_setup_entries',
|
||||
{
|
||||
id: id(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
section: text('section').notNull(),
|
||||
name: text('name').notNull(),
|
||||
note: text('note'),
|
||||
scope: text('scope'),
|
||||
link: text('link'),
|
||||
/** Free markdown text (custom instructions, longer notes). */
|
||||
body: text('body'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
|
||||
check(
|
||||
'provider_setup_entries_section_check',
|
||||
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
|
||||
),
|
||||
check(
|
||||
'provider_setup_entries_scope_check',
|
||||
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
|
||||
),
|
||||
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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,82 @@ 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()
|
||||
});
|
||||
|
||||
// Backfill bookkeeping (scripts/cs-history.ts): which config was fetched from which history
|
||||
// source, so the slow, throttled backfill can resume. History rows themselves live in
|
||||
// daily_prices under their own streams (kind ending in `-history`).
|
||||
export const csHistoryFetches = csSchema.table(
|
||||
'history_fetches',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
source: text('source').notNull(),
|
||||
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
points: integer('points').notNull().default(0),
|
||||
firstDay: date('first_day'),
|
||||
error: text('error')
|
||||
},
|
||||
(table) => [primaryKey({ name: 'history_fetches_pkey', columns: [table.itemId, table.source] })]
|
||||
);
|
||||
|
||||
@@ -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'). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
41
src/lib/server/investment-income.test.ts
Normal file
41
src/lib/server/investment-income.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/lib/server/investment-income.ts
Normal file
49
src/lib/server/investment-income.ts
Normal 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;
|
||||
}
|
||||
@@ -29,9 +29,10 @@ export const ownerOnlyPrefixes = [
|
||||
'/trackers/books/files',
|
||||
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
||||
'/trackers/notes',
|
||||
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
|
||||
// AI tracker: the news tab (/trackers/ai) is public, bots, usage and setup are mine only.
|
||||
'/trackers/ai/bots',
|
||||
'/trackers/ai/usage',
|
||||
'/trackers/ai/setup',
|
||||
'/lists/db',
|
||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||
'/api/spotify',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
src/lib/worldtodo/MapPicker.svelte
Normal file
155
src/lib/worldtodo/MapPicker.svelte
Normal 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>
|
||||
@@ -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)}>
|
||||
|
||||
760
src/lib/worldtodo/WorldMap.svelte
Normal file
760
src/lib/worldtodo/WorldMap.svelte
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|
||||
40
src/lib/worldtodo/cluster.test.ts
Normal file
40
src/lib/worldtodo/cluster.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
45
src/lib/worldtodo/cluster.ts
Normal file
45
src/lib/worldtodo/cluster.ts
Normal 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)}`;
|
||||
@@ -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;
|
||||
|
||||
54
src/lib/worldtodo/tiles.ts
Normal file
54
src/lib/worldtodo/tiles.ts
Normal 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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors';
|
||||
const MAPTILER =
|
||||
'© <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;
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
23
src/routes/projects/cs-prices/[slug]/+page.server.ts
Normal file
23
src/routes/projects/cs-prices/[slug]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
653
src/routes/projects/cs-prices/[slug]/+page.svelte
Normal file
653
src/routes/projects/cs-prices/[slug]/+page.svelte
Normal file
@@ -0,0 +1,653 @@
|
||||
<script lang="ts">
|
||||
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
|
||||
import { phaseOrder, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
type Config = (typeof data.configs)[number];
|
||||
type Variant = 'normal' | 'stattrak' | 'souvenir';
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
'skinport:lowest-listing': '#3b82f6',
|
||||
'skinport:sale-median-24h': '#22c55e',
|
||||
'steam-market:sale-avg': '#a855f7',
|
||||
'steam-market:lowest-listing': '#d946ef',
|
||||
'csfloat:lowest-listing': '#f59e0b',
|
||||
'buff163:lowest-listing': '#ef4444',
|
||||
'steam-market:sale-median-history': '#14b8a6',
|
||||
'csfloat:sale-avg-history': '#a3e635'
|
||||
};
|
||||
const sourceNames: Record<string, string> = {
|
||||
skinport: 'Skinport',
|
||||
'steam-market': 'Steam',
|
||||
csfloat: 'CSFloat',
|
||||
buff163: 'Buff163'
|
||||
};
|
||||
const kindNames: Record<string, string> = {
|
||||
'lowest-listing': 'lowest listing',
|
||||
'sale-median-24h': 'sold, 24h median',
|
||||
'sale-avg': 'sold, average',
|
||||
'sale-median-history': 'sold, daily median (history)',
|
||||
'sale-avg-history': 'sold, daily average (history)'
|
||||
};
|
||||
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
||||
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
|
||||
const count = new Intl.NumberFormat('nl-NL');
|
||||
const money = (cents: number | null | undefined) =>
|
||||
cents == null ? '–' : eur.format(cents / 100);
|
||||
|
||||
let streamById = $derived(new Map(data.streams.map((s) => [s.id, s])));
|
||||
let selected = $derived(data.configs.find((c) => c.id === data.selected) ?? null);
|
||||
const variantOf = (c: { stattrak: boolean; souvenir: boolean }): Variant =>
|
||||
c.souvenir ? 'souvenir' : c.stattrak ? 'stattrak' : 'normal';
|
||||
let variants = $derived(
|
||||
(['normal', 'stattrak', 'souvenir'] as Variant[]).filter((v) =>
|
||||
data.configs.some((c) => variantOf(c) === v)
|
||||
)
|
||||
);
|
||||
let currentVariant = $derived(selected ? variantOf(selected) : 'normal');
|
||||
let wears = $derived(
|
||||
wearOrder.filter((w) =>
|
||||
data.configs.some((c) => c.wear === w && variantOf(c) === currentVariant)
|
||||
)
|
||||
);
|
||||
let phases = $derived(
|
||||
phaseOrder.filter((p) =>
|
||||
data.configs.some(
|
||||
(c) =>
|
||||
c.phase === p && variantOf(c) === currentVariant && c.wear === (selected?.wear ?? null)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/** Best price to show for a config: lowest EUR listing, else the latest sale. */
|
||||
function best(config: Config) {
|
||||
const listings = config.prices.filter(
|
||||
(p) => streamById.get(p.stream)?.kind === 'lowest-listing'
|
||||
);
|
||||
const pool = listings.length ? listings : config.prices;
|
||||
return pool.length ? Math.min(...pool.map((p) => p.eur_cents)) : null;
|
||||
}
|
||||
function find(v: Variant, wear: string | null, phase: string | null) {
|
||||
return data.configs.find((c) => variantOf(c) === v && c.wear === wear && c.phase === phase);
|
||||
}
|
||||
function href(config: Config | undefined | null) {
|
||||
if (!config) return '?';
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', variantOf(config));
|
||||
if (config.wear) params.set('wear', wearShort[config.wear]);
|
||||
if (config.phase) params.set('phase', config.phase);
|
||||
return `?${params}`;
|
||||
}
|
||||
function forVariant(v: Variant) {
|
||||
return (
|
||||
find(v, selected?.wear ?? null, selected?.phase ?? null) ??
|
||||
find(v, selected?.wear ?? null, null) ??
|
||||
data.configs.find((c) => variantOf(c) === v && c.prices.length) ??
|
||||
data.configs.find((c) => variantOf(c) === v)
|
||||
);
|
||||
}
|
||||
function forWear(w: string) {
|
||||
return find(currentVariant, w, selected?.phase ?? null) ?? find(currentVariant, w, null);
|
||||
}
|
||||
function configLabel(c: Config) {
|
||||
const parts = [];
|
||||
if (c.souvenir) parts.push('Souvenir');
|
||||
if (c.stattrak) parts.push('StatTrak™');
|
||||
if (c.wear) parts.push(c.wear);
|
||||
if (c.phase) parts.push(c.phase);
|
||||
return parts.join(' · ') || 'Standard';
|
||||
}
|
||||
function streamLabel(id: number, anyPhase = false) {
|
||||
const s = streamById.get(id);
|
||||
if (!s) return `Stream ${id}`;
|
||||
return `${sourceNames[s.source] ?? s.source} ${kindNames[s.kind] ?? s.kind.replaceAll('-', ' ')}${anyPhase ? ' (any phase)' : ''}`;
|
||||
}
|
||||
|
||||
let range = $state('all');
|
||||
let cutoff = $derived.by(() => {
|
||||
if (range === 'all') return 0;
|
||||
const days = { '30d': 30, '90d': 90, '1y': 365 }[range] ?? 0;
|
||||
return Date.now() - days * 86_400_000;
|
||||
});
|
||||
let series = $derived<PriceSeries[]>(
|
||||
(data.history?.series ?? [])
|
||||
.map((s) => {
|
||||
const stream = streamById.get(s.stream);
|
||||
return {
|
||||
key: `${s.stream}:${s.anyPhase}`,
|
||||
name: streamLabel(s.stream, s.anyPhase),
|
||||
color: stream ? colors[`${stream.source}:${stream.kind}`] : undefined,
|
||||
dashed: s.anyPhase,
|
||||
points: s.points
|
||||
.map(([day, cents]) => [Date.parse(day), cents] as [number, number])
|
||||
.filter(([t]) => t >= cutoff)
|
||||
};
|
||||
})
|
||||
.filter((s) => s.points.length)
|
||||
);
|
||||
let days = $derived(new Set(series.flatMap((s) => s.points.map(([t]) => t))).size);
|
||||
let current = $derived(
|
||||
(selected?.prices ?? [])
|
||||
.map((p) => ({ ...p, stream: streamById.get(p.stream), id: p.stream }))
|
||||
.sort((a, b) => a.eur_cents - b.eur_cents)
|
||||
);
|
||||
const spanOrder = ['24h', '7d', '30d', '90d'];
|
||||
let windows = $derived(
|
||||
[...(data.history?.windows ?? [])].sort(
|
||||
(a, b) =>
|
||||
a.source.localeCompare(b.source) || spanOrder.indexOf(a.span) - spanOrder.indexOf(b.span)
|
||||
)
|
||||
);
|
||||
let windowSources = $derived([...new Set(windows.map((w) => w.source))]);
|
||||
const fxCents = (cents: number | null, currency: string) =>
|
||||
cents == null
|
||||
? null
|
||||
: currency === 'USD'
|
||||
? Math.round(cents * (data.history?.fx?.rate ?? 0.92))
|
||||
: cents;
|
||||
let title = $derived(
|
||||
selected && selected.market_hash_name !== data.skin.name
|
||||
? `${selected.market_hash_name}${selected.phase ? ` (${selected.phase})` : ''}`
|
||||
: data.skin.name
|
||||
);
|
||||
let image = $derived(selected?.icon_url ?? data.skin.image);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · CS prices · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={`Daily ${data.skin.name} prices for every wear, StatTrak, Souvenir and phase, from Skinport, Steam, CSFloat and Buff163.`}
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="cs-item">
|
||||
<a class="back" href="/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>
|
||||
@@ -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`);
|
||||
|
||||
13
src/routes/projects/cs-prices/api/skins/+server.ts
Normal file
13
src/routes/projects/cs-prices/api/skins/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
|
||||
{/if}
|
||||
</p>
|
||||
<div class="results-bar">
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if isMap}· {mapListed.length} on the map{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
|
||||
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
|
||||
// /trackers/ai: the news tab is public; bots, usage and setup are owner-only (their own loads call
|
||||
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
const owner = isOwner(event);
|
||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Newspaper from 'lucide-svelte/icons/newspaper';
|
||||
import Bot from 'lucide-svelte/icons/bot';
|
||||
import Gauge from 'lucide-svelte/icons/gauge';
|
||||
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
@@ -14,7 +15,8 @@
|
||||
...(data.owner
|
||||
? [
|
||||
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
|
||||
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
|
||||
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true },
|
||||
{ name: 'setup', href: '/trackers/ai/setup', icon: SlidersHorizontal, locked: true }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
@@ -25,7 +27,10 @@
|
||||
<TrackerIcon name="ai" class="size-10" />
|
||||
<div>
|
||||
<h1>ai</h1>
|
||||
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
|
||||
<p>
|
||||
AI and software news, plus what my bots are doing, how much I use each model and how each
|
||||
one is set up.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="ai-tabs" aria-label="AI tracker sections">
|
||||
@@ -118,4 +123,7 @@
|
||||
.tab-usage {
|
||||
background: linear-gradient(135deg, #34d399, #0d9488);
|
||||
}
|
||||
.tab-setup {
|
||||
background: linear-gradient(135deg, #a78bfa, #6d28d9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
|
||||
// header_already_set (500) for the owner.
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {
|
||||
bots: await loadBots(),
|
||||
now: Date.now(),
|
||||
|
||||
45
src/routes/trackers/ai/setup/+page.server.ts
Normal file
45
src/routes/trackers/ai/setup/+page.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import {
|
||||
addEntry,
|
||||
addProvider,
|
||||
deleteEntry,
|
||||
deleteProvider,
|
||||
loadSetup,
|
||||
updateEntry,
|
||||
updateProvider
|
||||
} from '#lib/server/ai-setup.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad, RequestEvent } from './$types';
|
||||
|
||||
// Owner only: how each AI provider is set up (connections, skills, instructions, ...).
|
||||
// Actions don't run the load, so each one checks the owner again (hooks.server.ts also 404s
|
||||
// this path for everyone else).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// No setHeaders here: the ai layout already sets cache-control for the owner (setting it
|
||||
// twice throws), and hooks.server.ts adds private, no-store on owner-only paths.
|
||||
requireOwner(event);
|
||||
return {
|
||||
providers: await loadSetup(),
|
||||
now: Date.now(),
|
||||
seo: { title: 'AI tracker · setup', description: 'How my AI tools are set up.', index: false }
|
||||
};
|
||||
};
|
||||
|
||||
type Handler = (form: FormData) => Promise<{ ok: true } | { ok: false; message: string }>;
|
||||
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const r = await fn(form);
|
||||
return r.ok
|
||||
? { ok: true as const }
|
||||
: fail(400, { message: r.message, scope: String(form.get('form_key') ?? '') });
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addProvider: action(addProvider),
|
||||
updateProvider: action(updateProvider),
|
||||
deleteProvider: action(deleteProvider),
|
||||
addEntry: action(addEntry),
|
||||
updateEntry: action(updateEntry),
|
||||
deleteEntry: action(deleteEntry)
|
||||
};
|
||||
834
src/routes/trackers/ai/setup/+page.svelte
Normal file
834
src/routes/trackers/ai/setup/+page.svelte
Normal file
@@ -0,0 +1,834 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import Plug from 'lucide-svelte/icons/plug';
|
||||
import WandSparkles from 'lucide-svelte/icons/wand-sparkles';
|
||||
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||
import Puzzle from 'lucide-svelte/icons/puzzle';
|
||||
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
|
||||
import Bot from 'lucide-svelte/icons/bot';
|
||||
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
type Provider = (typeof data.providers)[number];
|
||||
type Entry = Provider['entries'][number];
|
||||
type SectionKey = Entry['section'];
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
key: 'connections',
|
||||
label: 'Connections',
|
||||
hint: 'connectors, MCP servers, integrations',
|
||||
icon: Plug,
|
||||
from: '#38bdf8',
|
||||
to: '#2563eb'
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
label: 'Skills',
|
||||
hint: '',
|
||||
icon: WandSparkles,
|
||||
from: '#fbbf24',
|
||||
to: '#ea580c'
|
||||
},
|
||||
{
|
||||
key: 'instructions',
|
||||
label: 'Custom instructions',
|
||||
hint: 'markdown',
|
||||
icon: ScrollText,
|
||||
from: '#a78bfa',
|
||||
to: '#7c3aed'
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
label: 'Plugins & extensions',
|
||||
hint: '',
|
||||
icon: Puzzle,
|
||||
from: '#f472b6',
|
||||
to: '#db2777'
|
||||
},
|
||||
{
|
||||
key: 'routines',
|
||||
label: 'Scheduled tasks & routines',
|
||||
hint: '',
|
||||
icon: CalendarClock,
|
||||
from: '#34d399',
|
||||
to: '#059669'
|
||||
},
|
||||
{ key: 'agents', label: 'Agents & bots', hint: '', icon: Bot, from: '#fb923c', to: '#dc2626' },
|
||||
{
|
||||
key: 'plan',
|
||||
label: 'Plan & API keys',
|
||||
hint: 'labels only',
|
||||
icon: KeyRound,
|
||||
from: '#facc15',
|
||||
to: '#ca8a04'
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
hint: 'markdown',
|
||||
icon: StickyNote,
|
||||
from: '#a1a1aa',
|
||||
to: '#52525b'
|
||||
}
|
||||
] as const satisfies readonly { key: SectionKey }[];
|
||||
const MARKDOWN: SectionKey[] = ['instructions', 'notes'];
|
||||
|
||||
const COLORS = {
|
||||
orange: ['#fdba74', '#c2410c'],
|
||||
green: ['#6ee7b7', '#047857'],
|
||||
blue: ['#93c5fd', '#1d4ed8'],
|
||||
violet: ['#c4b5fd', '#6d28d9'],
|
||||
pink: ['#f9a8d4', '#be185d'],
|
||||
sky: ['#7dd3fc', '#0369a1'],
|
||||
amber: ['#fde68a', '#b45309'],
|
||||
zinc: ['#d4d4d8', '#3f3f46']
|
||||
} as const;
|
||||
type Color = keyof typeof COLORS;
|
||||
const colorKeys = Object.keys(COLORS) as Color[];
|
||||
|
||||
/** Which inline form is open: p:new, p:<id>, e:<id> or n:<providerId>:<section>. */
|
||||
let editing = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
/** Form key of the last submit, so a validation error shows next to the right form. */
|
||||
let lastKey = $state('');
|
||||
|
||||
const open = (key: string) => {
|
||||
editing = editing === key ? null : key;
|
||||
};
|
||||
const submit =
|
||||
(key: string, confirmText?: string): SubmitFunction =>
|
||||
({ cancel }) => {
|
||||
if (confirmText && !confirm(confirmText)) return cancel();
|
||||
busy = true;
|
||||
lastKey = key;
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
busy = false;
|
||||
if (result.type === 'success') editing = null;
|
||||
};
|
||||
};
|
||||
const errorFor = (key: string) => (form?.message && lastKey === key ? form.message : null);
|
||||
|
||||
const grouped = (p: Provider) => {
|
||||
const out = {} as Record<SectionKey, Entry[]>;
|
||||
for (const e of p.entries) (out[e.section] ??= []).push(e);
|
||||
return out;
|
||||
};
|
||||
const stamp = (iso: string) =>
|
||||
new Date(iso).toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const host = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet providerForm(p?: Provider)}
|
||||
{@const key = p ? `p:${p.id}` : 'p:new'}
|
||||
<form
|
||||
class="edit"
|
||||
method="POST"
|
||||
action={p ? '?/updateProvider' : '?/addProvider'}
|
||||
use:enhance={submit(key)}
|
||||
>
|
||||
<input type="hidden" name="form_key" value={key} />
|
||||
{#if p}<input type="hidden" name="id" value={p.id} />{/if}
|
||||
<label>name<input name="name" value={p?.name ?? ''} maxlength="60" required /></label>
|
||||
<label
|
||||
>products<input
|
||||
name="products"
|
||||
value={p?.products ?? ''}
|
||||
maxlength="120"
|
||||
placeholder="e.g. app + CLI"
|
||||
/></label
|
||||
>
|
||||
<fieldset class="colors">
|
||||
<legend>colour</legend>
|
||||
{#each colorKeys as c (c)}
|
||||
<label class="color" title={c}>
|
||||
<input type="radio" name="color" value={c} checked={(p?.color ?? 'zinc') === c} />
|
||||
<span style:--from={COLORS[c][0]} style:--to={COLORS[c][1]}></span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{p ? 'save' : 'add provider'}</button>
|
||||
</div>
|
||||
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
{#snippet entryForm(p: Provider, section: SectionKey, e?: Entry)}
|
||||
{@const key = e ? `e:${e.id}` : `n:${p.id}:${section}`}
|
||||
<form
|
||||
class="edit"
|
||||
method="POST"
|
||||
action={e ? '?/updateEntry' : '?/addEntry'}
|
||||
use:enhance={submit(key)}
|
||||
>
|
||||
<input type="hidden" name="form_key" value={key} />
|
||||
<input type="hidden" name="section" value={section} />
|
||||
{#if e}<input type="hidden" name="id" value={e.id} />{:else}<input
|
||||
type="hidden"
|
||||
name="provider_id"
|
||||
value={p.id}
|
||||
/>{/if}
|
||||
<label class="wide"
|
||||
>name<input
|
||||
name="name"
|
||||
value={e?.name ?? ''}
|
||||
maxlength="120"
|
||||
required
|
||||
placeholder={section === 'plan' ? 'e.g. Pro plan, or a key label like "server key"' : ''}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>scope<select name="scope" value={e?.scope ?? ''}>
|
||||
<option value="">—</option>
|
||||
<option value="global">global</option>
|
||||
<option value="per-repo">per-repo</option>
|
||||
<option value="app">app</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>link<input
|
||||
name="link"
|
||||
type="url"
|
||||
value={e?.link ?? ''}
|
||||
maxlength="500"
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>note<input
|
||||
name="note"
|
||||
value={e?.note ?? ''}
|
||||
maxlength="400"
|
||||
placeholder="short note"
|
||||
/></label
|
||||
>
|
||||
{#if MARKDOWN.includes(section) || e?.body}
|
||||
<label class="wide"
|
||||
>{section === 'instructions' ? 'instructions' : 'details'} (markdown)<textarea
|
||||
name="body"
|
||||
rows="6"
|
||||
value={e?.body ?? ''}
|
||||
maxlength="20000"></textarea></label
|
||||
>
|
||||
{/if}
|
||||
{#if section === 'plan'}
|
||||
<p class="hint wide">
|
||||
<KeyRound size={12} /> Labels only: never paste a key value. Anything that looks like a key or
|
||||
token is refused.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{e ? 'save' : 'add'}</button>
|
||||
</div>
|
||||
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
<div class="bar">
|
||||
<p class="meta">
|
||||
{data.providers.length} providers · connections, skills and instructions per tool · labels only, never
|
||||
keys
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
aria-expanded={editing === 'p:new'}
|
||||
onclick={() => open('p:new')}><Plus size={15} />add provider</button
|
||||
>
|
||||
</div>
|
||||
{#if editing === 'p:new'}
|
||||
<div class="card new">{@render providerForm()}</div>
|
||||
{/if}
|
||||
|
||||
{#if !data.providers.length}
|
||||
<p class="empty">No providers yet.</p>
|
||||
{/if}
|
||||
|
||||
<div class="cards">
|
||||
{#each data.providers as p (p.id)}
|
||||
{@const groups = grouped(p)}
|
||||
{@const [from, to] = COLORS[p.color as Color] ?? COLORS.zinc}
|
||||
{@const emptySections = SECTIONS.filter((s) => !groups[s.key])}
|
||||
<article class="card" style:--from={from} style:--to={to}>
|
||||
<header class="head">
|
||||
<span class="avatar" aria-hidden="true">{p.name.slice(0, 1).toUpperCase()}</span>
|
||||
<div class="title">
|
||||
<h2>{p.name}</h2>
|
||||
{#if p.products}<p class="products">{p.products}</p>{/if}
|
||||
</div>
|
||||
<time datetime={p.updatedAt} title={`${stamp(p.updatedAt)} (Amsterdam)`}
|
||||
>updated {relativeTime(p.updatedAt, data.now)}</time
|
||||
>
|
||||
<div class="tools">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit {p.name}"
|
||||
title="edit"
|
||||
onclick={() => open(`p:${p.id}`)}><Pencil size={14} /></button
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteProvider"
|
||||
use:enhance={submit(
|
||||
`pd:${p.id}`,
|
||||
`Delete ${p.name} and its ${p.entries.length} entries?`
|
||||
)}
|
||||
>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" class="icon danger" aria-label="delete {p.name}" title="delete"
|
||||
><Trash2 size={14} /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{#if editing === `p:${p.id}`}{@render providerForm(p)}{/if}
|
||||
|
||||
{#each SECTIONS as s (s.key)}
|
||||
{@const list = groups[s.key]}
|
||||
{#if list || editing === `n:${p.id}:${s.key}`}
|
||||
<details class="section" open={(list?.length ?? 0) <= 8}>
|
||||
<summary>
|
||||
<span class="sicon" style:--from={s.from} style:--to={s.to}><s.icon size={13} /></span
|
||||
>
|
||||
<span class="slabel">{s.label}</span>
|
||||
{#if s.hint}<span class="shint">{s.hint}</span>{/if}
|
||||
<span class="count">{list?.length ?? 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon add"
|
||||
aria-label="add to {s.label}"
|
||||
title="add"
|
||||
onclick={(ev) => {
|
||||
ev.preventDefault();
|
||||
open(`n:${p.id}:${s.key}`);
|
||||
}}><Plus size={14} /></button
|
||||
>
|
||||
</summary>
|
||||
{#if editing === `n:${p.id}:${s.key}`}{@render entryForm(p, s.key)}{/if}
|
||||
{#if list}
|
||||
<ul class="entries">
|
||||
{#each list as e (e.id)}
|
||||
<li class="entry">
|
||||
{#if editing === `e:${e.id}`}
|
||||
{@render entryForm(p, s.key, e)}
|
||||
{:else}
|
||||
<div class="eline">
|
||||
<span class="ename">{e.name}</span>
|
||||
{#if e.scope}<span class="scope scope-{e.scope}">{e.scope}</span>{/if}
|
||||
{#if e.link}
|
||||
<a
|
||||
class="elink"
|
||||
href={e.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={e.link}><ExternalLink size={12} />{host(e.link)}</a
|
||||
>
|
||||
{/if}
|
||||
<span class="etools">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit {e.name}"
|
||||
title="edit"
|
||||
onclick={() => open(`e:${e.id}`)}><Pencil size={13} /></button
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteEntry"
|
||||
use:enhance={submit(`ed:${e.id}`, `Delete “${e.name}”?`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="icon danger"
|
||||
aria-label="delete {e.name}"
|
||||
title="delete"><Trash2 size={13} /></button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{#if e.note}<p class="enote">{e.note}</p>{/if}
|
||||
{#if e.bodyHtml}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written markdown, rendered on the server with raw HTML escaped -->
|
||||
<div class="md">{@html e.bodyHtml}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if s.key === 'plan' && list && editing !== `n:${p.id}:${s.key}`}
|
||||
<p class="hint"><KeyRound size={12} /> Labels only, never key values.</p>
|
||||
{/if}
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if emptySections.some((s) => editing !== `n:${p.id}:${s.key}`)}
|
||||
<div class="chips">
|
||||
{#each emptySections as s (s.key)}
|
||||
{#if editing !== `n:${p.id}:${s.key}`}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => open(`n:${p.id}:${s.key}`)}
|
||||
style:--from={s.from}
|
||||
style:--to={s.to}><span class="sicon"><s.icon size={11} /></span>{s.label}</button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.4rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.icon:hover {
|
||||
color: var(--site-text);
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.icon.danger:hover {
|
||||
color: #f87171;
|
||||
background: rgb(248 113 113 / 0.1);
|
||||
}
|
||||
/* Masonry-style columns: cards differ a lot in height (Grok has dozens of entries). */
|
||||
.cards {
|
||||
columns: 2 26rem;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
.cards > .card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas: 'avatar title tools' 'avatar time tools';
|
||||
column-gap: 0.7rem;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.7rem;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
}
|
||||
.title {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.products {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.head time {
|
||||
grid-area: time;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.tools {
|
||||
grid-area: tools;
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.section {
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.sicon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.45rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
.shint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.entry {
|
||||
padding: 0.35rem 0 0.35rem 1.85rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.entry + .entry {
|
||||
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
|
||||
}
|
||||
.eline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ename {
|
||||
font-weight: 500;
|
||||
}
|
||||
.scope {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.scope-global {
|
||||
color: #38bdf8;
|
||||
border-color: rgb(56 189 248 / 0.4);
|
||||
}
|
||||
.scope-per-repo {
|
||||
color: #a78bfa;
|
||||
border-color: rgb(167 139 250 / 0.4);
|
||||
}
|
||||
.scope-app {
|
||||
color: #34d399;
|
||||
border-color: rgb(52 211 153 / 0.4);
|
||||
}
|
||||
.elink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.elink:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.etools {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.entry:hover .etools,
|
||||
.entry:focus-within .etools {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.etools {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.enote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.md {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-left: 2px solid var(--site-border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md :global(p + p),
|
||||
.md :global(ul),
|
||||
.md :global(ol) {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.md :global(ul) {
|
||||
list-style: disc;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.md :global(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.md :global(code) {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.md :global(a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
||||
border: 1px dashed var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.chip .sicon {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.edit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
.entry .edit {
|
||||
margin-left: -1.85rem;
|
||||
}
|
||||
.edit label,
|
||||
.colors legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.edit .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.edit input:not([type='radio']),
|
||||
.edit select,
|
||||
.edit textarea {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit input:not([type='radio']),
|
||||
.edit select {
|
||||
height: 2.1rem;
|
||||
}
|
||||
.edit textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.colors legend {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.color input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.color span {
|
||||
display: block;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.45rem;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color input:checked + span {
|
||||
outline-color: var(--site-text);
|
||||
}
|
||||
.color input:focus-visible + span {
|
||||
outline-color: #38bdf8;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
color: #ca8a04;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.edit .hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.new {
|
||||
padding: 0.2rem 0.85rem 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,9 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
// Owner only: dated usage numbers per provider. Actions don't run the load, so each
|
||||
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
|
||||
// header_already_set (500) for the owner.
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {
|
||||
entries: await loadUsage(),
|
||||
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user