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",
|
"wildlife:refresh": "scripts/wildlife-refresh.sh",
|
||||||
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
||||||
"cs-prices:ingest": "bun scripts/ingest-cs-prices.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:worker": "bun scripts/house-worker.ts",
|
||||||
"house": "bun scripts/house-listing.ts",
|
"house": "bun scripts/house-listing.ts",
|
||||||
"house:add": "bun scripts/house-listing.ts add",
|
"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)
|
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
|
||||||
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
|
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
|
// 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.
|
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
|
||||||
const AI_TABLES = 3;
|
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
|
||||||
|
const AI_TABLES = 5;
|
||||||
const [ai] = await client`
|
const [ai] = await client`
|
||||||
select
|
select
|
||||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
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]
|
* 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]
|
* 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 }[]>`
|
const [row] = await sql<{ id: number }[]>`
|
||||||
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
|
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
|
||||||
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
|
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,
|
name = EXCLUDED.name,
|
||||||
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
|
icon_url = COALESCE(EXCLUDED.icon_url, cs.items.icon_url),
|
||||||
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
|
item_type = COALESCE(EXCLUDED.item_type, cs.items.item_type),
|
||||||
last_seen_at = now()
|
last_seen_at = now()
|
||||||
RETURNING id`;
|
RETURNING id`;
|
||||||
const inferred = dimensionsFromName(name);
|
const inferred = dimensionsFromName(name);
|
||||||
@@ -281,7 +282,7 @@ try {
|
|||||||
else if (mode === 'history') await history();
|
else if (mode === 'history') await history();
|
||||||
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
|
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>');
|
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 {
|
} finally {
|
||||||
await sql.end();
|
await sql.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
// category 'transfer' (e.g. Personal <-> Joint legs)
|
// category 'transfer' (e.g. Personal <-> Joint legs)
|
||||||
// --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the
|
// --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the
|
||||||
// existing tracker rows those statement rows matched
|
// 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)
|
// --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
|
// --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
|
// statement opens at €0 and, after the import, the only difference
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
planImport,
|
planImport,
|
||||||
rowsFromTable,
|
rowsFromTable,
|
||||||
selectRows,
|
selectRows,
|
||||||
|
statementCategory,
|
||||||
statementExternalId,
|
statementExternalId,
|
||||||
type StatementRow,
|
type StatementRow,
|
||||||
type TrackerRow
|
type TrackerRow
|
||||||
@@ -293,12 +296,23 @@ try {
|
|||||||
insert into money.transactions
|
insert into money.transactions
|
||||||
(user_id, account_id, date, value_date, description, amount_cents, external_id, source, booking_status, category, raw)
|
(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)},
|
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 } })})
|
${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`;
|
on conflict do nothing`;
|
||||||
inserted += res.count;
|
inserted += res.count;
|
||||||
}
|
}
|
||||||
console.log(`Inserted ${inserted} rows into ${account.name}.`);
|
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')) {
|
if (transferRe && flag('--tag-matched-transfers')) {
|
||||||
const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id);
|
const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id);
|
||||||
const tagged = ids.length
|
const tagged = ids.length
|
||||||
|
|||||||
@@ -2,13 +2,30 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ECharts } from 'echarts/core';
|
import type { ECharts } from 'echarts/core';
|
||||||
|
|
||||||
export type PriceSeries = { key: string; name: string; points: [number, number][] };
|
export type PriceSeries = {
|
||||||
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
|
key: string;
|
||||||
$props();
|
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 element: HTMLDivElement;
|
||||||
let chart: ECharts | undefined;
|
let chart: ECharts | undefined;
|
||||||
let ready = $state(false);
|
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) =>
|
const escapeHtml = (value: string) =>
|
||||||
value.replace(
|
value.replace(
|
||||||
/[&<>"']/g,
|
/[&<>"']/g,
|
||||||
@@ -22,15 +39,53 @@
|
|||||||
const foreground = style.getPropertyValue('--site-text').trim();
|
const foreground = style.getPropertyValue('--site-text').trim();
|
||||||
const muted = style.getPropertyValue('--site-muted').trim();
|
const muted = style.getPropertyValue('--site-muted').trim();
|
||||||
const border = style.getPropertyValue('--site-border').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(
|
chart.setOption(
|
||||||
{
|
{
|
||||||
animation: false,
|
animation: false,
|
||||||
backgroundColor: 'transparent',
|
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: {
|
xAxis: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
|
min: short ? first - pad : undefined,
|
||||||
|
max: short ? last + pad : undefined,
|
||||||
|
minInterval: day,
|
||||||
axisLine: { lineStyle: { color: border } },
|
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 }
|
splitLine: { show: false }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
@@ -39,44 +94,71 @@
|
|||||||
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
|
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
|
||||||
splitLine: { lineStyle: { color: border } }
|
splitLine: { lineStyle: { color: border } }
|
||||||
},
|
},
|
||||||
dataZoom: [
|
dataZoom: zoom
|
||||||
{ type: 'inside' },
|
? [
|
||||||
{
|
{ type: 'inside' },
|
||||||
type: 'slider',
|
{
|
||||||
bottom: 12,
|
type: 'slider',
|
||||||
height: 18,
|
bottom: 10,
|
||||||
borderColor: border,
|
height: 18,
|
||||||
textStyle: { color: muted },
|
borderColor: border,
|
||||||
fillerColor: 'rgba(127,127,127,.15)'
|
textStyle: { color: muted },
|
||||||
}
|
fillerColor: 'rgba(127,127,127,.15)'
|
||||||
],
|
}
|
||||||
tooltip:
|
]
|
||||||
series.length > 100
|
: [],
|
||||||
? { show: false }
|
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',
|
trigger: 'item',
|
||||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
backgroundColor: surface,
|
||||||
borderColor: border,
|
borderColor: border,
|
||||||
textStyle: { color: foreground },
|
textStyle: { color: foreground },
|
||||||
formatter: (params: { seriesName: string; value: [number, number] }) =>
|
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) => ({
|
series: series.map((item, index) => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: item.points,
|
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,
|
connectNulls: false,
|
||||||
silent: series.length > 100,
|
silent: many,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: series.length > 100 ? 0.7 : 1.8,
|
width: many ? 0.7 : 1.8,
|
||||||
opacity: series.length > 100 ? 0.42 : 0.9
|
opacity: many ? 0.42 : 0.9,
|
||||||
|
type: item.dashed ? 'dashed' : 'solid'
|
||||||
},
|
},
|
||||||
itemStyle: {
|
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 } },
|
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
|
||||||
large: true
|
large: many
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
@@ -90,7 +172,7 @@
|
|||||||
import('echarts/core').then(async (echarts) => {
|
import('echarts/core').then(async (echarts) => {
|
||||||
const [
|
const [
|
||||||
{ LineChart },
|
{ LineChart },
|
||||||
{ GridComponent, TooltipComponent, DataZoomComponent },
|
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
|
||||||
{ CanvasRenderer }
|
{ CanvasRenderer }
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import('echarts/charts'),
|
import('echarts/charts'),
|
||||||
@@ -98,7 +180,14 @@
|
|||||||
import('echarts/renderers')
|
import('echarts/renderers')
|
||||||
]);
|
]);
|
||||||
if (disposed) return;
|
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' });
|
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||||
ready = true;
|
ready = true;
|
||||||
update();
|
update();
|
||||||
@@ -107,7 +196,7 @@
|
|||||||
theme = new MutationObserver(update);
|
theme = new MutationObserver(update);
|
||||||
theme.observe(document.documentElement, {
|
theme.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['data-theme']
|
attributeFilter: ['data-theme', 'class']
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -118,12 +207,16 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Track the props so a new selection redraws.
|
||||||
|
void series;
|
||||||
|
void currency;
|
||||||
if (ready) update();
|
if (ready) update();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="chart"
|
class="chart"
|
||||||
|
style:height
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${series.length} ${kind} price series chart`}
|
aria-label={`${series.length} ${kind} price series chart`}
|
||||||
@@ -132,7 +225,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(62vh, 560px);
|
min-height: 260px;
|
||||||
min-height: 320px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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(' ');
|
.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) {
|
function segments(item: ChartSeries) {
|
||||||
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
|
if (!item.projectedFrom) return { actual: item.points, projected: [] };
|
||||||
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
|
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
|
||||||
return split < 0
|
return split < 0
|
||||||
? { solid: [], dashed: item.points }
|
? { actual: [], projected: item.points }
|
||||||
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
|
: { 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) {
|
function pointAt(points: ChartPoint[], date: string) {
|
||||||
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
|
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
|
||||||
return points.findLast((point) => point.date <= date) ?? null;
|
return points.findLast((point) => point.date <= date) ?? null;
|
||||||
@@ -236,16 +239,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each series as item (item.label)}
|
{#each series as item (item.label)}
|
||||||
{@const parts = segments(item)}
|
{@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}
|
{#if points.length > 1}
|
||||||
<path
|
<path
|
||||||
d={path(points)}
|
d={path(points)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={item.color}
|
stroke={item.color}
|
||||||
stroke-width={item.emphasis ? 3 : 1.75}
|
stroke-width={lineWidth(item, index === 1)}
|
||||||
stroke-dasharray={index ? '6 5' : undefined}
|
stroke-opacity={index ? 0.8 : 1}
|
||||||
stroke-linecap={index ? 'butt' : 'round'}
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
data-line={index ? 'projected' : 'actual'}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -306,16 +310,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
|
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
|
||||||
<figcaption class="legend">
|
<figcaption class="legend">
|
||||||
{#each series as item (item.label)}
|
{#each series as item (item.label)}
|
||||||
<span class:emphasis={item.emphasis}
|
<span class:emphasis={item.emphasis}
|
||||||
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
{#if series.some((item) => item.dashedFrom)}
|
{#if series.some((item) => item.projectedFrom)}
|
||||||
<span class="line-key first"><span class="key solid"></span>Actual</span>
|
<span class="line-key first"><span class="key actual"></span>Actual</span>
|
||||||
<span class="line-key"><span class="key dashed"></span>Projected</span>
|
<span class="line-key"><span class="key projected"></span>Projected</span>
|
||||||
{/if}
|
{/if}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -419,10 +423,12 @@
|
|||||||
.key {
|
.key {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
border-top: 2px solid var(--site-muted);
|
border-top: 3px solid var(--site-muted);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.key.dashed {
|
.key.projected {
|
||||||
border-top-style: dashed;
|
border-top-width: 1.5px;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.swatch {
|
.swatch {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
|
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
|
||||||
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
|
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
|
||||||
{ key: 'correctionPercent', label: 'Mid-cycle correction', 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: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
|
||||||
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
|
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
|
||||||
]
|
]
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
|
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
|
||||||
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
|
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
|
||||||
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
|
{ 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: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
|
||||||
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
|
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
|
||||||
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
|
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
|
||||||
@@ -101,7 +103,21 @@
|
|||||||
: 'Not by the end';
|
: 'Not by the end';
|
||||||
let active = $derived(params[selected]);
|
let active = $derived(params[selected]);
|
||||||
let result = $derived(results[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. */
|
/** Salary path rows through the retirement year. */
|
||||||
let career = $derived(
|
let career = $derived(
|
||||||
result.years.filter(
|
result.years.filter(
|
||||||
@@ -163,11 +179,24 @@
|
|||||||
<p class="muted scenario-portfolio">
|
<p class="muted scenario-portfolio">
|
||||||
<strong>{active.label} portfolio:</strong>
|
<strong>{active.label} portfolio:</strong>
|
||||||
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
|
{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
|
{#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`
|
? `, 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}
|
{: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
|
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
|
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
|
||||||
the rest of the spending is sold from investments.
|
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
|
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}
|
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
|
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.
|
grow with inflation (the plateau can grow faster, see above); spending grows at the spending
|
||||||
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
|
rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
|
||||||
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
|
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/"
|
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
|
||||||
rel="external noreferrer">Chase IT 2026</a
|
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');
|
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([
|
expect(total.points).toEqual([
|
||||||
{ date: '2026-09-01', value: 50000 },
|
{ date: '2026-09-01', value: 50000 },
|
||||||
{ date: '2026-09-10', value: 70000 },
|
{ date: '2026-09-10', value: 70000 },
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export type ChartSeries = {
|
|||||||
color: string;
|
color: string;
|
||||||
points: ChartPoint[];
|
points: ChartPoint[];
|
||||||
emphasis?: boolean;
|
emphasis?: boolean;
|
||||||
/** Points after this date are drawn dashed (projected). */
|
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
|
||||||
dashedFrom?: string;
|
projectedFrom?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
|
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
|
* 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.
|
* 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[] {
|
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
|
||||||
const limit = Math.max(2, Math.floor(buckets)) * 2;
|
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);
|
const keep = new Uint8Array(line.points.length);
|
||||||
keep[0] = 1;
|
keep[0] = 1;
|
||||||
keep[line.points.length - 1] = 1;
|
keep[line.points.length - 1] = 1;
|
||||||
if (line.dashedFrom) {
|
if (line.projectedFrom) {
|
||||||
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
|
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
|
||||||
if (join >= 0) keep[join] = 1;
|
if (join >= 0) keep[join] = 1;
|
||||||
}
|
}
|
||||||
let bucket = -1;
|
let bucket = -1;
|
||||||
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
|
|||||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
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.
|
* today) so both read as one continuous line per measure.
|
||||||
*/
|
*/
|
||||||
export function wealthTimeline(
|
export function wealthTimeline(
|
||||||
@@ -379,7 +379,7 @@ export function wealthTimeline(
|
|||||||
label: 'Total',
|
label: 'Total',
|
||||||
color: 'var(--site-text)',
|
color: 'var(--site-text)',
|
||||||
emphasis: true,
|
emphasis: true,
|
||||||
dashedFrom: today,
|
projectedFrom: today,
|
||||||
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
|
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
|
||||||
};
|
};
|
||||||
if (view === 'accounts')
|
if (view === 'accounts')
|
||||||
@@ -395,13 +395,13 @@ export function wealthTimeline(
|
|||||||
{
|
{
|
||||||
label: 'Spendable',
|
label: 'Spendable',
|
||||||
color: '#46a758',
|
color: '#46a758',
|
||||||
dashedFrom: today,
|
projectedFrom: today,
|
||||||
points: join(sumKind(false), spendableProjection?.points ?? [])
|
points: join(sumKind(false), spendableProjection?.points ?? [])
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Invested',
|
label: 'Invested',
|
||||||
color: '#8e4ec6',
|
color: '#8e4ec6',
|
||||||
dashedFrom: today,
|
projectedFrom: today,
|
||||||
points: join(sumKind(true), investedProjection?.points ?? [])
|
points: join(sumKind(true), investedProjection?.points ?? [])
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -189,6 +189,49 @@ describe('runScenario', () => {
|
|||||||
expect(markets.yearReturn(2034).kind).toBe('correction');
|
expect(markets.yearReturn(2034).kind).toBe('correction');
|
||||||
expect(markets.yearReturn(2028).kind).toBe('normal');
|
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', () => {
|
it('hops jobs, caps the salary and retires at the chosen age', () => {
|
||||||
const long = { ...inputs, endDate: '2126-10-24' };
|
const long = { ...inputs, endDate: '2126-10-24' };
|
||||||
const result = runScenario(long, {
|
const result = runScenario(long, {
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export type ScenarioParams = {
|
|||||||
firstCrashYear: number;
|
firstCrashYear: number;
|
||||||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||||||
correctionPercent: number;
|
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. */
|
/** Interest on the cash buffer, percent per year. */
|
||||||
cashRatePercent: number;
|
cashRatePercent: number;
|
||||||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||||||
@@ -62,6 +67,8 @@ export type ScenarioParams = {
|
|||||||
hopGapMonths: number;
|
hopGapMonths: number;
|
||||||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||||||
salaryCapEuros: number;
|
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. */
|
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||||||
bonusPercentOfTarget: number;
|
bonusPercentOfTarget: number;
|
||||||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
/** 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,
|
crashEveryYears: 8,
|
||||||
firstCrashYear: 2027,
|
firstCrashYear: 2027,
|
||||||
correctionPercent: -8,
|
correctionPercent: -8,
|
||||||
|
calmYears: 0,
|
||||||
cashRatePercent: 0,
|
cashRatePercent: 0,
|
||||||
inflationPercent: 2.5,
|
inflationPercent: 2.5,
|
||||||
gapMonths: 3,
|
gapMonths: 3,
|
||||||
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
|||||||
hopRaisePercent: 15,
|
hopRaisePercent: 15,
|
||||||
hopGapMonths: 3,
|
hopGapMonths: 3,
|
||||||
salaryCapEuros: 75000,
|
salaryCapEuros: 75000,
|
||||||
|
salaryCapGrowthPercent: 0,
|
||||||
bonusPercentOfTarget: 0,
|
bonusPercentOfTarget: 0,
|
||||||
sideStartEuros: 100,
|
sideStartEuros: 100,
|
||||||
sideEndEuros: 100,
|
sideEndEuros: 100,
|
||||||
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
|||||||
crashEveryYears: 10,
|
crashEveryYears: 10,
|
||||||
firstCrashYear: 2029,
|
firstCrashYear: 2029,
|
||||||
correctionPercent: -10,
|
correctionPercent: -10,
|
||||||
|
calmYears: 0,
|
||||||
cashRatePercent: 1.5,
|
cashRatePercent: 1.5,
|
||||||
inflationPercent: 2.5,
|
inflationPercent: 2.5,
|
||||||
gapMonths: 0,
|
gapMonths: 0,
|
||||||
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
|||||||
hopRaisePercent: 20,
|
hopRaisePercent: 20,
|
||||||
hopGapMonths: 0,
|
hopGapMonths: 0,
|
||||||
salaryCapEuros: 95000,
|
salaryCapEuros: 95000,
|
||||||
|
salaryCapGrowthPercent: 0,
|
||||||
bonusPercentOfTarget: 100,
|
bonusPercentOfTarget: 100,
|
||||||
sideStartEuros: 250,
|
sideStartEuros: 250,
|
||||||
sideEndEuros: 500,
|
sideEndEuros: 500,
|
||||||
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
|||||||
crashEveryYears: 10,
|
crashEveryYears: 10,
|
||||||
firstCrashYear: 2030,
|
firstCrashYear: 2030,
|
||||||
correctionPercent: -15,
|
correctionPercent: -15,
|
||||||
|
calmYears: 5,
|
||||||
cashRatePercent: 1.5,
|
cashRatePercent: 1.5,
|
||||||
inflationPercent: 2.5,
|
inflationPercent: 2.5,
|
||||||
gapMonths: 0,
|
gapMonths: 0,
|
||||||
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
|||||||
hopRaisePercent: 22,
|
hopRaisePercent: 22,
|
||||||
hopGapMonths: 0,
|
hopGapMonths: 0,
|
||||||
salaryCapEuros: 150000,
|
salaryCapEuros: 150000,
|
||||||
|
salaryCapGrowthPercent: 2,
|
||||||
bonusPercentOfTarget: 100,
|
bonusPercentOfTarget: 100,
|
||||||
sideStartEuros: 300,
|
sideStartEuros: 300,
|
||||||
sideEndEuros: 2500,
|
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
|
* 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 pct = (value: number) => value / 100;
|
||||||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||||||
const crashes = params.crashPercent !== 0;
|
const crashes = params.crashPercent !== 0;
|
||||||
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
|
|||||||
const normal = crashes
|
const normal = crashes
|
||||||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||||||
: pct(params.returnPercent);
|
: pct(params.returnPercent);
|
||||||
|
const calmUntil =
|
||||||
|
startYear !== undefined && params.calmYears > 0
|
||||||
|
? startYear + Math.round(params.calmYears)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
normal,
|
normal,
|
||||||
|
calmUntil,
|
||||||
yearReturn(year: number) {
|
yearReturn(year: number) {
|
||||||
|
if (calmUntil !== null && year <= calmUntil)
|
||||||
|
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
|
||||||
if (!crashes || year < params.firstCrashYear)
|
if (!crashes || year < params.firstCrashYear)
|
||||||
return { kind: 'normal' as const, rate: normal };
|
return { kind: 'normal' as const, rate: normal };
|
||||||
const offset = (year - params.firstCrashYear) % every;
|
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 gapStart = addMonths(facts.contractEnd, 1);
|
||||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||||
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
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 monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||||||
const isHopYear = (year: number) =>
|
const isHopYear = (year: number) =>
|
||||||
params.hopEveryYears > 0 &&
|
params.hopEveryYears > 0 &&
|
||||||
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
!(hopStart && month === 10)
|
!(hopStart && month === 10)
|
||||||
)
|
)
|
||||||
gross *= 1 + pct(params.raisePercent);
|
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)
|
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||||
if (hopEnd && first === hopEnd && !retired) {
|
if (hopEnd && first === hopEnd && !retired) {
|
||||||
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
// compounding gives the same monthly return.
|
// compounding gives the same monthly return.
|
||||||
const market = markets.yearReturn(year);
|
const market = markets.yearReturn(year);
|
||||||
const monthRate =
|
const monthRate =
|
||||||
market.kind === 'normal'
|
market.kind === 'normal' || market.kind === 'calm'
|
||||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||||
: month >= 3 && month <= 10
|
: month >= 3 && month <= 10
|
||||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
? 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(
|
export function scenarioCompareLines(
|
||||||
results: Record<ScenarioId, ScenarioResult>,
|
results: Record<ScenarioId, ScenarioResult>,
|
||||||
params: Record<ScenarioId, ScenarioParams>,
|
params: Record<ScenarioId, ScenarioParams>,
|
||||||
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
|
|||||||
return scenarioIds.map((id) => ({
|
return scenarioIds.map((id) => ({
|
||||||
label: params[id].label,
|
label: params[id].label,
|
||||||
color: scenarioColors[id],
|
color: scenarioColors[id],
|
||||||
dashedFrom: today,
|
projectedFrom: today,
|
||||||
points: results[id].points
|
points: results[id].points
|
||||||
.filter((point) => point.date > today)
|
.filter((point) => point.date > today)
|
||||||
.map((point) => ({
|
.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', () => {
|
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', () => {
|
it('parses decimal amounts exactly', () => {
|
||||||
expect(amountToCents('12.34')).toBe(1234);
|
expect(amountToCents('12.34')).toBe(1234);
|
||||||
expect(amountToCents('-0.5')).toBe(-50);
|
expect(amountToCents('-0.5')).toBe(-50);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SessionResponse
|
SessionResponse
|
||||||
} from './enable-banking';
|
} from './enable-banking';
|
||||||
import { EnableBankingError } from './enable-banking';
|
import { EnableBankingError } from './enable-banking';
|
||||||
|
import { investmentIncomeCategory } from './investment-income';
|
||||||
import { findSumMatch } from './reconcile';
|
import { findSumMatch } from './reconcile';
|
||||||
|
|
||||||
export type MappedTransaction = {
|
export type MappedTransaction = {
|
||||||
@@ -21,6 +22,8 @@ export type MappedTransaction = {
|
|||||||
counterparty: string | null;
|
counterparty: string | null;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
bookingStatus: string;
|
bookingStatus: string;
|
||||||
|
/** 'interest' / 'dividend' for investment income payouts, else null. */
|
||||||
|
category: string | null;
|
||||||
raw: BankTransaction;
|
raw: BankTransaction;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +82,17 @@ export function mapBankTransaction(tx: BankTransaction): MappedTransaction | nul
|
|||||||
counterparty,
|
counterparty,
|
||||||
amountCents,
|
amountCents,
|
||||||
bookingStatus: tx.status ?? 'BOOK',
|
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
|
raw: tx
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -435,10 +449,11 @@ export async function syncBankAccounts(
|
|||||||
for (const { row } of absorbed)
|
for (const { row } of absorbed)
|
||||||
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
|
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
|
||||||
for (const { id, tx } of updates) {
|
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`
|
await q`
|
||||||
update money.transactions set external_id = ${tx.externalId},
|
update money.transactions set external_id = ${tx.externalId},
|
||||||
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
||||||
|
category = coalesce(category, ${tx.category}),
|
||||||
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
||||||
raw = ${q.json(tx.raw as never)}
|
raw = ${q.json(tx.raw as never)}
|
||||||
where id = ${id} and external_id is null`;
|
where id = ${id} and external_id is null`;
|
||||||
@@ -447,10 +462,10 @@ export async function syncBankAccounts(
|
|||||||
await q`
|
await q`
|
||||||
insert into money.transactions
|
insert into money.transactions
|
||||||
(user_id, account_id, date, description, amount_cents, external_id, source,
|
(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},
|
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
|
||||||
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
|
${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`;
|
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 { 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 { aiSchema } from './schemas';
|
||||||
import { id, timestamps, timestamptz } from './helpers';
|
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`)
|
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,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
bigint
|
bigint,
|
||||||
|
boolean,
|
||||||
|
date,
|
||||||
|
real,
|
||||||
|
smallint,
|
||||||
|
numeric
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { csSchema } from './schemas';
|
import { csSchema } from './schemas';
|
||||||
import { id } from './helpers';
|
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.
|
// A Steam market hash name identifies a listing, including wear and special variants.
|
||||||
export const csMarketItems = csSchema.table(
|
export const csMarketItems = csSchema.table(
|
||||||
'items',
|
'items',
|
||||||
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
iconUrl: text('icon_url'),
|
iconUrl: text('icon_url'),
|
||||||
itemType: text('item_type'),
|
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(),
|
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('items_hash_idx').on(table.marketHashName),
|
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
|
||||||
index('items_name_idx').on(table.name)
|
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(),
|
source: text('source').notNull(),
|
||||||
currency: text('currency').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) => [
|
(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_STATUSES = ['open', 'in_progress', 'done'] as const;
|
||||||
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||||
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] 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' });
|
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ export const worldtodoRequestStatus = worldtodoSchema.enum(
|
|||||||
WORLDTODO_REQUEST_STATUSES
|
WORLDTODO_REQUEST_STATUSES
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const worldtodoLocationPrecision = worldtodoSchema.enum(
|
||||||
|
'location_precision',
|
||||||
|
WORLDTODO_LOCATION_PRECISIONS
|
||||||
|
);
|
||||||
|
|
||||||
export const worldtodoCategories = worldtodoSchema.table('categories', {
|
export const worldtodoCategories = worldtodoSchema.table('categories', {
|
||||||
slug: text('slug').primaryKey(),
|
slug: text('slug').primaryKey(),
|
||||||
label: text('label').notNull(),
|
label: text('label').notNull(),
|
||||||
@@ -61,6 +67,13 @@ export const worldtodoTasks = worldtodoSchema.table(
|
|||||||
countryCode: text('country_code'),
|
countryCode: text('country_code'),
|
||||||
lat: doublePrecision('lat'),
|
lat: doublePrecision('lat'),
|
||||||
lon: doublePrecision('lon'),
|
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'),
|
status: worldtodoStatus('status').notNull().default('open'),
|
||||||
sourceUrls: text('source_urls').array().notNull(),
|
sourceUrls: text('source_urls').array().notNull(),
|
||||||
/** Date of the main source as given ('2026-09', '2026-10-21'). */
|
/** 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;
|
creditor_account?: { iban?: string | null } | null;
|
||||||
debtor_account?: { iban?: string | null } | null;
|
debtor_account?: { iban?: string | null } | null;
|
||||||
remittance_information?: string[] | 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;
|
merchant_category_code?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export type GoalMetricDetail = {
|
|||||||
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
|
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
|
||||||
|
|
||||||
export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m';
|
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';
|
const TZ = 'Europe/Amsterdam';
|
||||||
|
|
||||||
type SourceRow = {
|
type SourceRow = {
|
||||||
@@ -54,6 +58,18 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
|||||||
select value, detail, computed_at, error from tasks.metric_sources
|
select value, detail, computed_at, error from tasks.metric_sources
|
||||||
where key = ${SIDE_INCOME_SOURCE}`;
|
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) => ({
|
const parts: GoalMetricPart[] = rows.map((r) => ({
|
||||||
key: `recurring-${r.id}`,
|
key: `recurring-${r.id}`,
|
||||||
label: r.name,
|
label: r.name,
|
||||||
@@ -71,6 +87,12 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
|||||||
: 'not fetched yet',
|
: 'not fetched yet',
|
||||||
asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null
|
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 {
|
return {
|
||||||
value: round2(parts.reduce((s, p) => s + p.value, 0)),
|
value: round2(parts.reduce((s, p) => s + p.value, 0)),
|
||||||
detail: {
|
detail: {
|
||||||
@@ -87,7 +109,7 @@ export const GOAL_METRICS: Record<
|
|||||||
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
|
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
|
||||||
> = {
|
> = {
|
||||||
'own-net-income-monthly': {
|
'own-net-income-monthly': {
|
||||||
label: 'Own net income / month (salary + side income)',
|
label: 'Own net income / month (salary + side + investment income)',
|
||||||
compute: ownNetIncomeMonthly
|
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',
|
'/trackers/books/files',
|
||||||
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
||||||
'/trackers/notes',
|
'/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/bots',
|
||||||
'/trackers/ai/usage',
|
'/trackers/ai/usage',
|
||||||
|
'/trackers/ai/setup',
|
||||||
'/lists/db',
|
'/lists/db',
|
||||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||||
'/api/spotify',
|
'/api/spotify',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
statementCategory,
|
||||||
checkBalances,
|
checkBalances,
|
||||||
parseAmount,
|
parseAmount,
|
||||||
parseRevolutCsv,
|
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', () => {
|
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', () => {
|
it('parses amounts in both notations', () => {
|
||||||
expect(parseAmount('1,234.56')).toBe(123456);
|
expect(parseAmount('1,234.56')).toBe(123456);
|
||||||
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).
|
// 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
|
// 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.
|
// (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)
|
).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,
|
EDITABLE_FIELDS,
|
||||||
RESERVED_SLUGS,
|
RESERVED_SLUGS,
|
||||||
changedFields,
|
changedFields,
|
||||||
|
precisionFor,
|
||||||
type Category,
|
type Category,
|
||||||
type PublicTask,
|
type PublicTask,
|
||||||
type RequestKind,
|
type RequestKind,
|
||||||
@@ -18,7 +19,8 @@ import {
|
|||||||
|
|
||||||
const TASK_COLUMNS = sql`
|
const TASK_COLUMNS = sql`
|
||||||
t.id, t.slug, t.title, t.description, t.scale::text as scale, t.category,
|
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.source_date, t.confidence, t.verification_note, t.as_of::text as as_of,
|
||||||
t.created_at, t.updated_at, t.deleted_at`;
|
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() ?? '';
|
const iso = (v: unknown) => toDate(v)?.toISOString() ?? '';
|
||||||
|
|
||||||
export function toTask(r: Row): PublicTask {
|
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 {
|
return {
|
||||||
id: Number(r.id),
|
id: Number(r.id),
|
||||||
slug: String(r.slug),
|
slug: String(r.slug),
|
||||||
title: String(r.title),
|
title: String(r.title),
|
||||||
description: String(r.description),
|
description: String(r.description),
|
||||||
scale: r.scale as PublicTask['scale'],
|
scale,
|
||||||
category: String(r.category),
|
category: String(r.category),
|
||||||
locationName: String(r.location_name),
|
locationName: String(r.location_name),
|
||||||
countryCode: (r.country_code as string | null) ?? null,
|
countryCode: (r.country_code as string | null) ?? null,
|
||||||
lat: r.lat === null || r.lat === undefined ? null : Number(r.lat),
|
lat,
|
||||||
lon: r.lon === null || r.lon === undefined ? null : Number(r.lon),
|
lon,
|
||||||
|
locationPrecision:
|
||||||
|
(r.location_precision as PublicTask['locationPrecision'] | null) ??
|
||||||
|
precisionFor(scale, lat, lon),
|
||||||
status: r.status as PublicTask['status'],
|
status: r.status as PublicTask['status'],
|
||||||
sourceUrls: (r.source_urls as string[]) ?? [],
|
sourceUrls: (r.source_urls as string[]) ?? [],
|
||||||
sourceDate: (r.source_date as string | null) ?? null,
|
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">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import IconTile from './IconTile.svelte';
|
import IconTile from './IconTile.svelte';
|
||||||
|
import MapPicker from './MapPicker.svelte';
|
||||||
import { SCALE_ICONS } from './icons';
|
import { SCALE_ICONS } from './icons';
|
||||||
import {
|
import {
|
||||||
SCALE_INFO,
|
SCALE_INFO,
|
||||||
@@ -212,6 +213,7 @@
|
|||||||
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
|
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MapPicker bind:lat bind:lon />
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<fieldset class="field" aria-describedby={described('source_urls', true)}>
|
<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 type RequestKind = (typeof WORLDTODO_REQUEST_KINDS)[number];
|
||||||
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||||
export type RequestStatus = (typeof WORLDTODO_REQUEST_STATUSES)[number];
|
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). */
|
/** The only keys a request payload may carry (checked server-side). */
|
||||||
export const EDITABLE_FIELDS = [
|
export const EDITABLE_FIELDS = [
|
||||||
@@ -66,6 +79,8 @@ export type PublicTask = {
|
|||||||
countryCode: string | null;
|
countryCode: string | null;
|
||||||
lat: number | null;
|
lat: number | null;
|
||||||
lon: number | null;
|
lon: number | null;
|
||||||
|
/** Map pin kind (see precisionFor). */
|
||||||
|
locationPrecision: LocationPrecision;
|
||||||
status: Status;
|
status: Status;
|
||||||
sourceUrls: string[];
|
sourceUrls: string[];
|
||||||
sourceDate: string | null;
|
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 { db } from '#lib/server/db/index.js';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { kinds, overview, searchSkins, streams } from '#lib/server/cs-prices.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const [stats, dimensions, markets] = await Promise.all([
|
const q = url.searchParams.get('q') ?? '';
|
||||||
db.execute(sql`SELECT
|
const kind = url.searchParams.get('kind') ?? '';
|
||||||
(SELECT count(*)::int FROM cs.items) AS items,
|
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||||
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
|
const [stats, dimensions, markets, skins] = await Promise.all([
|
||||||
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
|
overview(),
|
||||||
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
|
|
||||||
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
||||||
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
|
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 {
|
return {
|
||||||
stats: stats[0] as {
|
stats,
|
||||||
items: number;
|
|
||||||
observations: string;
|
|
||||||
earliest: Date | null;
|
|
||||||
latest: Date | null;
|
|
||||||
},
|
|
||||||
dimensions: dimensions as unknown as { key: string; values: number }[],
|
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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
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 = {
|
type Result = {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -40,15 +104,12 @@
|
|||||||
};
|
};
|
||||||
const number = new Intl.NumberFormat('en-US');
|
const number = new Intl.NumberFormat('en-US');
|
||||||
let dimension = $state('item');
|
let dimension = $state('item');
|
||||||
let sourceChoice = $state('steam-market|USD');
|
let sourceChoice = $state('skinport|EUR');
|
||||||
let kind = $state('sale-median');
|
let kind = $state('lowest-listing');
|
||||||
let markets = $derived(
|
let markets = $derived(
|
||||||
data.markets.length
|
data.markets.length
|
||||||
? data.markets
|
? data.markets
|
||||||
: [
|
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
|
||||||
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
|
|
||||||
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
let sourceOptions = $derived([
|
let sourceOptions = $derived([
|
||||||
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
|
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
|
||||||
@@ -90,7 +151,7 @@
|
|||||||
sourceChoice = value;
|
sourceChoice = value;
|
||||||
kind =
|
kind =
|
||||||
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
|
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
|
||||||
'sale-median';
|
'lowest-listing';
|
||||||
}
|
}
|
||||||
function add(row: Result) {
|
function add(row: Result) {
|
||||||
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
|
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
|
||||||
@@ -115,8 +176,8 @@
|
|||||||
}
|
}
|
||||||
const to = new SvelteDate();
|
const to = new SvelteDate();
|
||||||
const from =
|
const from =
|
||||||
range === 'all' && data.stats.earliest
|
range === 'all' && data.stats.earliest_day
|
||||||
? new SvelteDate(data.stats.earliest)
|
? new SvelteDate(data.stats.earliest_day)
|
||||||
: new SvelteDate(to);
|
: new SvelteDate(to);
|
||||||
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
|
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
|
||||||
if (range === '24h') from.setTime(to.getTime() - 86_400_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 === '30d') from.setUTCDate(from.getUTCDate() - 30);
|
||||||
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
|
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
|
||||||
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
|
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() };
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
}
|
}
|
||||||
function bucketLabel(seconds: number) {
|
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`;
|
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,19 +319,73 @@
|
|||||||
<p class="eyebrow">market archive / counter-strike</p>
|
<p class="eyebrow">market archive / counter-strike</p>
|
||||||
<h1>CS prices</h1>
|
<h1>CS prices</h1>
|
||||||
<p class="lede">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<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>
|
<div>
|
||||||
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
|
<strong>{number.format(data.stats.latest_rows ?? 0)}</strong><span
|
||||||
>stored observations</span
|
>prices {data.stats.latest_day ?? ''}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="workspace">
|
||||||
<aside class="picker" aria-label="Series selection">
|
<aside class="picker" aria-label="Series selection">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
@@ -357,11 +474,10 @@
|
|||||||
></label
|
></label
|
||||||
><label
|
><label
|
||||||
>Range<NativeSelect bind:value={range}
|
>Range<NativeSelect bind:value={range}
|
||||||
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
|
><option value="7d">7 days</option><option value="30d">30 days</option><option
|
||||||
value="7d">7 days</option
|
value="1y">1 year</option
|
||||||
><option value="30d">30 days</option><option value="1y">1 year</option><option
|
><option value="5y">5 years</option><option value="all">All history</option><option
|
||||||
value="5y">5 years</option
|
value="custom">Custom</option
|
||||||
><option value="all">All history</option><option value="custom">Custom</option
|
|
||||||
></NativeSelect
|
></NativeSelect
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
@@ -394,9 +510,9 @@
|
|||||||
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
|
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
|
||||||
'-',
|
'-',
|
||||||
' '
|
' '
|
||||||
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
|
)} · {bucketSeconds ? `${bucketLabel(bucketSeconds)} points` : 'choose a series'}. Group
|
||||||
Group lines average each listing equally within a bucket; raw observations remain in the
|
lines average each config equally per point. Open an item above for its own page with every
|
||||||
database.
|
config.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,6 +552,95 @@
|
|||||||
h3 {
|
h3 {
|
||||||
font-size: 0.85rem;
|
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 {
|
.lede {
|
||||||
color: var(--site-muted);
|
color: var(--site-muted);
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
@@ -637,6 +842,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.browse-bar {
|
||||||
|
grid-template-columns: 1fr 130px;
|
||||||
|
}
|
||||||
|
.hits {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
.intro {
|
.intro {
|
||||||
display: block;
|
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 maxSeries = 1000;
|
||||||
const maxDays = 365 * 20;
|
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 }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
|
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
|
||||||
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
|
const kind = typeof body.kind === 'string' ? body.kind : 'lowest-listing';
|
||||||
const source = typeof body.source === 'string' ? body.source : 'steam-market';
|
const source = typeof body.source === 'string' ? body.source : 'skinport';
|
||||||
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
|
const currency = typeof body.currency === 'string' ? body.currency : 'EUR';
|
||||||
const ids = Array.isArray(body.ids) ? body.ids : [];
|
const ids = Array.isArray(body.ids) ? body.ids : [];
|
||||||
const values = Array.isArray(body.values) ? body.values : [];
|
const values = Array.isArray(body.values) ? body.values : [];
|
||||||
if (
|
if (
|
||||||
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
)
|
)
|
||||||
return Response.json({ error: 'Invalid selection' }, { status: 400 });
|
return Response.json({ error: 'Invalid selection' }, { status: 400 });
|
||||||
if (dimension === 'item' ? !ids.length : !values.length)
|
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 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);
|
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)
|
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
|
||||||
return Response.json({ error: 'Invalid date range' }, { status: 400 });
|
return Response.json({ error: 'Invalid date range' }, { status: 400 });
|
||||||
// Aim for at most 240 buckets per series. Raw observations remain untouched.
|
const weekly = +to - +from > 400 * 86400_000;
|
||||||
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
const seconds = weekly ? 7 * 86400 : 86400;
|
||||||
const selection =
|
const selection =
|
||||||
dimension === 'item'
|
dimension === 'item'
|
||||||
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
|
? 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`,`
|
sql`,`
|
||||||
)})`;
|
)})`;
|
||||||
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
|
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`
|
const rows = await db.execute(sql`
|
||||||
WITH per_item AS (
|
WITH stream AS (
|
||||||
SELECT ${seriesKey} AS series_key, p.item_id,
|
SELECT id FROM cs.price_streams
|
||||||
date_bin(make_interval(secs => ${seconds}), p.observed_at,
|
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}
|
||||||
TIMESTAMPTZ '1970-01-01') AS bucket,
|
), per_item AS (
|
||||||
avg(p.price_cents) AS price_cents,
|
SELECT ${seriesKey} AS series_key, p.item_id, ${bucket} AS bucket,
|
||||||
sum(p.volume) AS volume,
|
avg(p.price_cents) AS price_cents, sum(p.volume) AS volume,
|
||||||
min(p.price_cents) AS min_cents,
|
min(p.price_cents) AS min_cents, max(p.price_cents) AS max_cents
|
||||||
max(p.price_cents) AS max_cents
|
FROM cs.daily_prices p ${selection}
|
||||||
FROM cs.price_observations p ${selection}
|
WHERE p.stream_id = (SELECT id FROM stream)
|
||||||
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
|
AND p.day >= ${from.toISOString().slice(0, 10)}::date
|
||||||
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
AND p.day <= ${to.toISOString().slice(0, 10)}::date
|
||||||
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
|
||||||
GROUP BY 1, 2, 3
|
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,
|
count(*)::int AS items, sum(volume)::bigint AS volume,
|
||||||
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
|
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`);
|
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 status = STATUSES.includes(first('status')) ? first('status') : 'todo';
|
||||||
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
|
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
|
||||||
const q = first('q').slice(0, 100);
|
const q = first('q').slice(0, 100);
|
||||||
|
const view = first('view') === 'map' ? 'map' : 'list';
|
||||||
const filter = { scale, category, status, q };
|
const filter = { scale, category, status, q };
|
||||||
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
|
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
|
||||||
return {
|
return {
|
||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
|
view,
|
||||||
tasks: list.tasks,
|
tasks: list.tasks,
|
||||||
counts: totals,
|
counts: totals,
|
||||||
seo: {
|
seo: {
|
||||||
title: 'The worldwide todo list',
|
title: 'The worldwide todo list',
|
||||||
description:
|
description:
|
||||||
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.',
|
'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">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import TaskCard from '#lib/worldtodo/TaskCard.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 IconTile from '#lib/worldtodo/IconTile.svelte';
|
||||||
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
||||||
import { subscribe } from '#lib/worldtodo/live';
|
import { subscribe } from '#lib/worldtodo/live';
|
||||||
@@ -12,6 +14,9 @@
|
|||||||
type PublicTask
|
type PublicTask
|
||||||
} from '#lib/worldtodo/shared';
|
} from '#lib/worldtodo/shared';
|
||||||
import Plus from 'lucide-svelte/icons/plus';
|
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';
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -90,6 +95,31 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const f = $derived(data.filter);
|
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);
|
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -185,16 +215,75 @@
|
|||||||
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
|
{#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.category}<input type="hidden" name="category" value={f.category} />{/if}
|
||||||
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/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>
|
</form>
|
||||||
|
|
||||||
<p class="results" aria-live="polite">
|
<div class="results-bar">
|
||||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
<p class="results" aria-live="polite">
|
||||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||||
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
|
{#if isMap}· {mapListed.length} on the map{/if}
|
||||||
{/if}
|
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||||
</p>
|
· <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>
|
<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">
|
<ul class="grid">
|
||||||
<li>
|
<li>
|
||||||
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
|
<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>
|
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{#each tasks as task (task.slug)}
|
{#each isMap ? mapListed : tasks as task (task.slug)}
|
||||||
<li>
|
<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
|
<TaskCard
|
||||||
{task}
|
{task}
|
||||||
category={catBySlug.get(task.category)}
|
category={catBySlug.get(task.category)}
|
||||||
@@ -379,6 +474,133 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--site-muted);
|
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 {
|
.results a {
|
||||||
color: var(--site-text);
|
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])
|
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) {
|
function tweakValues(item: Item) {
|
||||||
const base = item.task ? fieldsOf(item.task) : {};
|
const base = item.task ? fieldsOf(item.task) : {};
|
||||||
return { ...base, ...item.payload } as Record<string, unknown>;
|
return { ...base, ...item.payload } as Record<string, unknown>;
|
||||||
@@ -194,6 +204,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<dl class="meta">
|
<dl class="meta">
|
||||||
@@ -573,4 +592,10 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.osm {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isOwner } from '#lib/server/owner.js';
|
import { isOwner } from '#lib/server/owner.js';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
|
// /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 and /trackers/ai/usage as a backstop).
|
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
|
||||||
export const load: LayoutServerLoad = (event) => {
|
export const load: LayoutServerLoad = (event) => {
|
||||||
const owner = isOwner(event);
|
const owner = isOwner(event);
|
||||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Newspaper from 'lucide-svelte/icons/newspaper';
|
import Newspaper from 'lucide-svelte/icons/newspaper';
|
||||||
import Bot from 'lucide-svelte/icons/bot';
|
import Bot from 'lucide-svelte/icons/bot';
|
||||||
import Gauge from 'lucide-svelte/icons/gauge';
|
import Gauge from 'lucide-svelte/icons/gauge';
|
||||||
|
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
...(data.owner
|
...(data.owner
|
||||||
? [
|
? [
|
||||||
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
|
{ 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" />
|
<TrackerIcon name="ai" class="size-10" />
|
||||||
<div>
|
<div>
|
||||||
<h1>ai</h1>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav class="ai-tabs" aria-label="AI tracker sections">
|
<nav class="ai-tabs" aria-label="AI tracker sections">
|
||||||
@@ -118,4 +123,7 @@
|
|||||||
.tab-usage {
|
.tab-usage {
|
||||||
background: linear-gradient(135deg, #34d399, #0d9488);
|
background: linear-gradient(135deg, #34d399, #0d9488);
|
||||||
}
|
}
|
||||||
|
.tab-setup {
|
||||||
|
background: linear-gradient(135deg, #a78bfa, #6d28d9);
|
||||||
|
}
|
||||||
</style>
|
</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).
|
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
|
||||||
export const load: PageServerLoad = async (event) => {
|
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);
|
requireOwner(event);
|
||||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
|
||||||
return {
|
return {
|
||||||
bots: await loadBots(),
|
bots: await loadBots(),
|
||||||
now: Date.now(),
|
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
|
// 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).
|
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
|
||||||
export const load: PageServerLoad = async (event) => {
|
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);
|
requireOwner(event);
|
||||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
|
||||||
return {
|
return {
|
||||||
entries: await loadUsage(),
|
entries: await loadUsage(),
|
||||||
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
|
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import Sparkles from 'lucide-svelte/icons/sparkles';
|
import Sparkles from 'lucide-svelte/icons/sparkles';
|
||||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||||
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
||||||
|
import TrendingUp from 'lucide-svelte/icons/trending-up';
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -202,6 +203,8 @@
|
|||||||
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
|
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
|
||||||
// Auto metrics (goal.metricKey): stacked bar per part, colourful on the black/white base.
|
// 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 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 partPct = (v: number, target: number) => Math.max(0, Math.min(100, (v / target) * 100));
|
||||||
const pctLabel = (v: number, target: number) => {
|
const pctLabel = (v: number, target: number) => {
|
||||||
const p = (v / target) * 100;
|
const p = (v / target) * 100;
|
||||||
@@ -521,9 +524,9 @@
|
|||||||
aria-valuemax={target}
|
aria-valuemax={target}
|
||||||
aria-valuenow={goal.metricCurrent}
|
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: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)}`}
|
title={`${p.label}: ${money(p.value, goal.metricUnit)}`}
|
||||||
></span>{/each}
|
></span>{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -531,12 +534,14 @@
|
|||||||
{#each d.parts as p, i (p.key)}
|
{#each d.parts as p, i (p.key)}
|
||||||
<li>
|
<li>
|
||||||
<span class="dot" style:color={PART_COLORS[i % PART_COLORS.length]}
|
<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}
|
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="truncate" title={p.note ?? ''}>{p.label}</span>
|
||||||
<span class="amt">{money(p.value, goal.metricUnit)}</span>
|
<span class="amt">{money2(p.value)}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user