16 Commits

Author SHA1 Message Date
44379b9a27 math tracker: space before the skipped count
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
2026-10-26 11:22:53 +00:00
f7a3bb556c math tracker: pass string classes to lucide icons (fixes owner-view 500)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-26 11:14:03 +00:00
16684d6688 math tracker: own icon colour (blue sigma tile; indigo is games)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-26 11:09:36 +00:00
d99245e9fc Math tracker (/trackers/math): Evan Chen's Napkin, chapter by chapter
Some checks failed
Deploy production / deploy (push) Failing after 9s
Schema math (parts, chapters, sections, activity), seeded with all 100 chapters
(Ch 0-94, appendix A-E), 647 sections, PDF pages, exercise counts and approximate
prereqs. Triggers stamp started_at/completed_at and log status/exercise/note/section
changes; math.apply_progress(jsonb) applies the JSON format bots send. Private by
default via MATH_PRIVATE in #lib/math (owner-only prefix + lock in every tracker list).
Overall + per-part progress, current chapter, prereq hints, quick status buttons,
exercises stepper, notes, optional section ticks, activity log, dependency chart.
Deploy preflight checks the math schema (4 tables, 100 chapters).
2026-10-26 10:55:16 +00:00
23a43049a0 cs-prices: lime for CSFloat history so it stands apart from CSFloat listings
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:35:36 +00:00
3b75fd39b5 AI setup tab: masonry columns, even form controls, one labels-only hint
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-26 09:34:38 +00:00
76f063b362 cs-prices: price history backfill (Steam daily median since 2013 from the public listing page, CSFloat daily averages, ECB FX history) in separate *-history streams; labelled on item charts
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:32:55 +00:00
7396d23335 AI tracker: owner-only tabs no longer 500 (cache-control set twice: layout + page load)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 09:27:26 +00:00
22279d181f deploy preflight: ai schema now has 5 tables (setup tab)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:23:35 +00:00
8d2da984e0 AI tracker: owner-only Setup tab (per-provider connections, skills, instructions, plugins, routines, agents, plan/key labels, notes; ai.provider_setup_providers + provider_setup_entries, secrets refused)
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
2026-10-26 09:09:32 +00:00
9e89a615c6 cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly
- every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases
- cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163
- cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB)
- scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index
- skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts
- charts show points for short histories and pad sparse time axes
2026-10-26 08:47:57 +00:00
9d79dfb43a worldwide todo list: global map (List/Map toggle, clustered category pins, Global strip, pick on map, location_precision, tasks.geojson)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 08:38:51 +00:00
21b10ea894 Money forecast: name the first crash after the calm start in the explanation
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 08:29:28 +00:00
78b69c7d8f Money forecast: calm optimistic start, rising plateau, thin solid projected lines
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00
d3b9bb9ba2 money: tag interest/dividend payouts on bank sync and Revolut import (category interest|dividend, never overwrites a set category)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:48:47 +00:00
0121071af2 Tasks goals: investment income in the €10k/month bar (trailing-12m money.transactions amount_cents>0, category dividend|interest, any account except Joint, ÷12); own segment colour + breakdown line, breakdown amounts with cents
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:35:09 +00:00
76 changed files with 219782 additions and 176 deletions

View File

@@ -35,7 +35,8 @@ export default defineConfig({
'ai',
'movies',
'worldtodo',
'notes'
'notes',
'math'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,983 @@
CREATE SCHEMA "math";
--> statement-breakpoint
CREATE TABLE "math"."activity" (
"id" serial PRIMARY KEY,
"chapter_id" integer NOT NULL,
"section_id" integer,
"kind" text NOT NULL,
"from_value" text,
"to_value" text,
"delta" integer,
"source" text DEFAULT 'sql' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('status','exercises','note','section'))
);
--> statement-breakpoint
CREATE TABLE "math"."chapters" (
"id" serial PRIMARY KEY,
"key" text NOT NULL,
"part_id" integer NOT NULL,
"title" text NOT NULL,
"page_start" integer NOT NULL,
"page_end" integer NOT NULL,
"exercise_count" integer,
"prereqs" text[] DEFAULT '{}'::text[] NOT NULL,
"status" text DEFAULT 'not_started' NOT NULL,
"exercises_done" integer DEFAULT 0 NOT NULL,
"notes" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"sort_order" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chapters_status_check" CHECK ("status" IN ('not_started','reading','exercises','done','skipped')),
CONSTRAINT "chapters_exercises_done_check" CHECK ("exercises_done" >= 0)
);
--> statement-breakpoint
CREATE TABLE "math"."parts" (
"id" serial PRIMARY KEY,
"key" text NOT NULL,
"num" integer,
"title" text NOT NULL,
"sort_order" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "math"."sections" (
"id" serial PRIMARY KEY,
"chapter_id" integer NOT NULL,
"key" text NOT NULL,
"title" text NOT NULL,
"sort_order" integer NOT NULL,
"done_at" timestamp with time zone
);
--> statement-breakpoint
CREATE INDEX "activity_created_idx" ON "math"."activity" ("created_at");--> statement-breakpoint
CREATE INDEX "activity_chapter_idx" ON "math"."activity" ("chapter_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "chapters_key_key" ON "math"."chapters" ("key");--> statement-breakpoint
CREATE INDEX "chapters_part_idx" ON "math"."chapters" ("part_id","sort_order");--> statement-breakpoint
CREATE UNIQUE INDEX "parts_key_key" ON "math"."parts" ("key");--> statement-breakpoint
CREATE UNIQUE INDEX "sections_chapter_key_key" ON "math"."sections" ("chapter_id","key");--> statement-breakpoint
ALTER TABLE "math"."activity" ADD CONSTRAINT "activity_chapter_id_chapters_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "math"."chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
ALTER TABLE "math"."activity" ADD CONSTRAINT "activity_section_id_sections_id_fkey" FOREIGN KEY ("section_id") REFERENCES "math"."sections"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint
ALTER TABLE "math"."chapters" ADD CONSTRAINT "chapters_part_id_parts_id_fkey" FOREIGN KEY ("part_id") REFERENCES "math"."parts"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
ALTER TABLE "math"."sections" ADD CONSTRAINT "sections_chapter_id_chapters_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "math"."chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
-- Seed: the Napkin outline (parts, chapters with PDF page ranges, exercise counts and approximate
-- prereqs, sections) from Terence Tao's extraction of the book (2026-10-26). Progress starts empty.
INSERT INTO "math"."parts" ("key", "num", "title", "sort_order") VALUES
('1', 1, 'Starting Out', 1),
('2', 2, 'Basic Abstract Algebra', 2),
('3', 3, 'Basic Topology', 3),
('4', 4, 'Linear Algebra', 4),
('5', 5, 'More on Groups', 5),
('6', 6, 'Representation Theory', 6),
('7', 7, 'Quantum Algorithms', 7),
('8', 8, 'Calculus 101', 8),
('9', 9, 'Complex Analysis', 9),
('10', 10, 'Measure Theory', 10),
('11', 11, 'Probability (TO DO)', 11),
('12', 12, 'Differential Geometry', 12),
('13', 13, 'Riemann Surfaces', 13),
('14', 14, 'Algebraic NT I: Rings of Integers', 14),
('15', 15, 'Algebraic NT II: Galois and Ramification Theory', 15),
('16', 16, 'Algebraic Topology I: Homotopy', 16),
('17', 17, 'Category Theory', 17),
('18', 18, 'Algebraic Topology II: Homology', 18),
('19', 19, 'Algebraic Geometry I: Classical Varieties', 19),
('20', 20, 'Algebraic Geometry II: Affine Schemes', 20),
('21', 21, 'Set Theory I: ZFC, Ordinals, and Cardinals', 21),
('22', 22, 'Set Theory II: Model Theory and Forcing', 22),
('appendix', NULL, 'Appendix', 23);
--> statement-breakpoint
INSERT INTO "math"."chapters" ("key", "part_id", "title", "page_start", "page_end", "exercise_count", "prereqs", "sort_order") VALUES
('0', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Sales pitches', 37, 42, NULL, '{}'::text[], 1),
('1', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Groups', 43, 54, 9, '{}'::text[], 2),
('2', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Metric spaces', 55, 68, 6, '{}'::text[], 3),
('3', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Homomorphisms and quotient groups', 69, 80, 8, '{}'::text[], 4),
('4', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Rings and ideals', 81, 92, 8, '{}'::text[], 5),
('5', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Flavors of rings', 93, 108, 9, '{}'::text[], 6),
('6', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Properties of metric spaces', 109, 114, 5, '{}'::text[], 7),
('7', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Topological spaces', 115, 124, 8, '{}'::text[], 8),
('8', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Compactness', 125, 138, 9, '{}'::text[], 9),
('9', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Vector spaces', 139, 156, 9, '{1,3,4,5}'::text[], 10),
('10', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Eigen-things', 157, 166, 7, '{1,3,4,5}'::text[], 11),
('11', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Dual space and trace', 167, 174, 4, '{1,3,4,5}'::text[], 12),
('12', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Determinant', 175, 182, 8, '{1,3,4,5}'::text[], 13),
('13', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Inner product spaces', 183, 190, 8, '{1,3,4,5}'::text[], 14),
('14', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Bonus: Fourier analysis', 191, 200, 2, '{1,3,4,5}'::text[], 15),
('15', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Duals, adjoint, and transposes', 201, 212, 8, '{1,3,4,5}'::text[], 16),
('16', (SELECT id FROM "math"."parts" WHERE key = '5'), 'Group actions overkill AIME problems', 213, 218, 5, '{1,3,4,5}'::text[], 17),
('17', (SELECT id FROM "math"."parts" WHERE key = '5'), 'Find all groups', 219, 224, 5, '{16}'::text[], 18),
('18', (SELECT id FROM "math"."parts" WHERE key = '5'), 'The PID structure theorem', 225, 236, 5, '{1,3,4,5}'::text[], 19),
('19', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Representations of algebras', 237, 248, 5, '{9,10,11,12,13,14,15,18}'::text[], 20),
('20', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Semisimple algebras', 249, 256, 5, '{9,10,11,12,13,14,15,18}'::text[], 21),
('21', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Characters', 257, 264, 5, '{9,10,11,12,13,14,15,18}'::text[], 22),
('22', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Some applications', 265, 270, NULL, '{9,10,11,12,13,14,15,18}'::text[], 23),
('23', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Quantum states and measurements', 271, 278, 2, '{9,10,11,12,13,14,15,18}'::text[], 24),
('24', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Quantum circuits', 279, 286, 4, '{9,10,11,12,13,14,15,18}'::text[], 25),
('25', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Shor''s algorithm', 287, 294, NULL, '{9,10,11,12,13,14,15,18}'::text[], 26),
('26', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Limits and series', 295, 306, 8, '{2,6,7,8}'::text[], 27),
('27', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Bonus: A hint of p-adic numbers', 307, 318, 4, '{2,6,7,8}'::text[], 28),
('28', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Differentiation', 319, 330, 7, '{2,6,7,8}'::text[], 29),
('29', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Power series and Taylor series', 331, 338, 5, '{2,6,7,8}'::text[], 30),
('30', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Riemann integrals', 339, 348, 4, '{2,6,7,8}'::text[], 31),
('31', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Holomorphic functions', 349, 364, 6, '{26,27,28,29,30}'::text[], 32),
('32', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Meromorphic functions', 365, 374, 5, '{26,27,28,29,30}'::text[], 33),
('33', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Holomorphic square roots and logarithms', 375, 380, 2, '{26,27,28,29,30}'::text[], 34),
('34', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Bonus: Topological Abel-Ruffini Theorem', 381, 386, 1, '{26,27,28,29,30}'::text[], 35),
('35', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Measure spaces', 387, 394, 2, '{26,27,28,29,30}'::text[], 36),
('36', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Constructing the Borel and Lebesgue measure', 395, 404, 3, '{26,27,28,29,30}'::text[], 37),
('37', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Lebesgue integration', 405, 410, 2, '{26,27,28,29,30}'::text[], 38),
('38', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Swapping order with Lebesgue integrals', 411, 418, NULL, '{26,27,28,29,30}'::text[], 39),
('39', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Bonus: A hint of Pontryagin duality', 419, 426, 2, '{26,27,28,29,30}'::text[], 40),
('40', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Random variables (TO DO)', 427, 428, 2, '{35,36,37,38,39}'::text[], 41),
('41', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Large number laws (TO DO)', 429, 436, 2, '{35,36,37,38,39}'::text[], 42),
('42', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Stopped martingales (TO DO)', 437, 452, 6, '{35,36,37,38,39}'::text[], 43),
('43', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Multivariable calculus done correctly', 453, 460, 2, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 44),
('44', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Differential forms', 461, 472, 2, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 45),
('45', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Integrating differential forms', 473, 484, 7, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 46),
('46', (SELECT id FROM "math"."parts" WHERE key = '12'), 'A bit of manifolds', 485, 496, 1, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 47),
('47', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Basic definitions of Riemann surfaces', 497, 504, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 48),
('48', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Morphisms between Riemann surfaces', 505, 510, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 49),
('49', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Affine and projective plane curves', 511, 520, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 50),
('50', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Differential forms', 521, 524, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 51),
('51', (SELECT id FROM "math"."parts" WHERE key = '13'), 'The Riemann-Roch theorem', 525, 530, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 52),
('52', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Line bundles', 531, 540, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 53),
('53', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Algebraic integers', 541, 546, 9, '{9,10,11,12,13,14,15,18}'::text[], 54),
('54', (SELECT id FROM "math"."parts" WHERE key = '14'), 'The ring of integers', 547, 556, 6, '{9,10,11,12,13,14,15,18}'::text[], 55),
('55', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Unique factorization (finally!)', 557, 568, 5, '{9,10,11,12,13,14,15,18}'::text[], 56),
('56', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Minkowski bound and class groups', 569, 588, 6, '{9,10,11,12,13,14,15,18}'::text[], 57),
('57', (SELECT id FROM "math"."parts" WHERE key = '14'), 'More properties of the discriminant', 589, 590, 6, '{9,10,11,12,13,14,15,18}'::text[], 58),
('58', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Bonus: Let''s solve Pell''s equation!', 591, 598, 2, '{9,10,11,12,13,14,15,18}'::text[], 59),
('59', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Things Galois', 599, 610, 6, '{9,10,11,12,13,14,15,18}'::text[], 60),
('60', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Finite fields', 611, 616, 1, '{53,54,55,56,57,58,59}'::text[], 61),
('61', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Ramification theory', 617, 626, 1, '{53,54,55,56,57,58,59}'::text[], 62),
('62', (SELECT id FROM "math"."parts" WHERE key = '15'), 'The Frobenius element', 627, 638, 5, '{53,54,55,56,57,58,59}'::text[], 63),
('63', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Bonus: A Bit on Artin Reciprocity', 639, 654, 3, '{53,54,55,56,57,58,59}'::text[], 64),
('64', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Some topological constructions', 655, 666, 3, '{1,2,3,4,5,6,7,8}'::text[], 65),
('65', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Fundamental groups', 667, 678, 4, '{1,2,3,4,5,6,7,8}'::text[], 66),
('66', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Covering projections', 679, 692, NULL, '{1,2,3,4,5,6,7,8}'::text[], 67),
('67', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Objects and morphisms', 693, 704, 4, '{1,3,4,5}'::text[], 68),
('68', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Functors and natural transformations', 705, 716, 3, '{1,3,4,5}'::text[], 69),
('69', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Limits in categories (TO DO)', 717, 718, 1, '{1,3,4,5}'::text[], 70),
('70', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Abelian categories', 719, 728, 4, '{1,3,4,5}'::text[], 71),
('71', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Singular homology', 729, 742, 2, '{64,65,66,67,68,69,70}'::text[], 72),
('72', (SELECT id FROM "math"."parts" WHERE key = '18'), 'The long exact sequence', 743, 754, 6, '{64,65,66,67,68,69,70}'::text[], 73),
('73', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Excision and relative homology', 755, 762, 5, '{64,65,66,67,68,69,70}'::text[], 74),
('74', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Bonus: Cellular homology', 763, 774, 5, '{64,65,66,67,68,69,70}'::text[], 75),
('75', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Singular cohomology', 775, 786, 3, '{64,65,66,67,68,69,70}'::text[], 76),
('76', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Application of cohomology', 787, 806, 4, '{64,65,66,67,68,69,70}'::text[], 77),
('77', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Affine varieties', 807, 816, 3, '{1,3,4,5}'::text[], 78),
('78', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Affine varieties as ringed spaces', 817, 826, 3, '{1,3,4,5}'::text[], 79),
('79', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Projective varieties', 827, 834, NULL, '{1,3,4,5}'::text[], 80),
('80', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Bonus: Bézout''s theorem', 835, 842, 2, '{1,3,4,5}'::text[], 81),
('81', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Morphisms of varieties', 843, 854, 4, '{1,3,4,5}'::text[], 82),
('82', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Sheaves and ringed spaces', 855, 866, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 83),
('83', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Localization', 867, 876, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 84),
('84', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Affine schemes: the Zariski topology', 877, 886, 3, '{67,68,69,70,77,78,79,80,81}'::text[], 85),
('85', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Affine schemes: the sheaf', 887, 896, 5, '{67,68,69,70,77,78,79,80,81}'::text[], 86),
('86', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Interlude: eighteen examples of affine schemes', 897, 912, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 87),
('87', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Morphisms of locally ringed spaces', 913, 926, 2, '{67,68,69,70,77,78,79,80,81}'::text[], 88),
('88', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Interlude: Cauchy''s functional equation and Zorn''s lemma', 927, 934, 3, '{}'::text[], 89),
('89', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Zermelo-Fraenkel with choice', 935, 942, 5, '{}'::text[], 90),
('90', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Ordinals', 943, 954, 3, '{}'::text[], 91),
('91', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Cardinals', 955, 964, 4, '{}'::text[], 92),
('92', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Inner model theory', 965, 976, 4, '{}'::text[], 93),
('93', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Forcing', 977, 986, 2, '{}'::text[], 94),
('94', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Breaking the continuum hypothesis', 987, 994, 1, '{}'::text[], 95),
('A', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Pedagogical comments and references', 995, 998, NULL, '{}'::text[], 96),
('B', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Hints to selected problems', 999, 1010, NULL, '{}'::text[], 97),
('C', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Sketches of selected solutions', 1011, 1038, NULL, '{}'::text[], 98),
('D', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Glossary of notations', 1039, 1048, NULL, '{}'::text[], 99),
('E', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Terminology on sets and functions', 1049, 1052, NULL, '{}'::text[], 100);
--> statement-breakpoint
INSERT INTO "math"."sections" ("chapter_id", "key", "title", "sort_order")
SELECT c.id, v.key, v.title, v.ord FROM (VALUES
('0', '0.1', 'The basics', 1),
('0', '0.2', 'Abstract algebra', 2),
('0', '0.3', 'Real and complex analysis', 3),
('0', '0.4', 'Algebraic number theory', 4),
('0', '0.5', 'Algebraic topology', 5),
('0', '0.6', 'Algebraic geometry', 6),
('0', '0.7', 'Set theory', 7),
('1', '1.1', 'Definition and examples of groups', 1),
('1', '1.2', 'Properties of groups', 2),
('1', '1.3', 'Isomorphisms', 3),
('1', '1.4', 'Orders of groups, and Lagrange''s theorem', 4),
('1', '1.5', 'Subgroups', 5),
('1', '1.6', 'Groups of small orders', 6),
('1', '1.7', 'Unimportant long digression', 7),
('1', '1.8', 'A few harder problems to think about', 8),
('2', '2.1', 'Definition and examples of metric spaces', 1),
('2', '2.2', 'Convergence in metric spaces', 2),
('2', '2.3', 'Continuous maps', 3),
('2', '2.4', 'Homeomorphisms', 4),
('2', '2.5', 'Extended example/definition: product metric', 5),
('2', '2.6', 'Open sets', 6),
('2', '2.7', 'Closed sets', 7),
('2', '2.8', 'A few harder problems to think about', 8),
('3', '3.1', 'Generators and group presentations', 1),
('3', '3.2', 'Homomorphisms', 2),
('3', '3.3', 'Cosets and modding out', 3),
('3', '3.4', '(Optional) Proof of Lagrange''s theorem', 4),
('3', '3.5', 'Eliminating the homomorphism', 5),
('3', '3.6', '(Digression) The first isomorphism theorem', 6),
('3', '3.7', 'A few harder problems to think about', 7),
('4', '4.1', 'Some motivational metaphors about rings vs groups', 1),
('4', '4.2', '(Optional) Pedagogical notes on motivation', 2),
('4', '4.3', 'Definition and examples of rings', 3),
('4', '4.4', 'Fields', 4),
('4', '4.5', 'Homomorphisms', 5),
('4', '4.6', 'Ideals', 6),
('4', '4.7', 'Generating ideals', 7),
('4', '4.8', 'Principal ideal domains', 8),
('4', '4.9', 'Noetherian rings', 9),
('4', '4.10', 'A few harder problems to think about', 10),
('5', '5.1', 'Fields', 1),
('5', '5.2', 'Integral domains', 2),
('5', '5.3', 'Prime ideals', 3),
('5', '5.4', 'Maximal ideals', 4),
('5', '5.5', 'Field of fractions', 5),
('5', '5.6', 'Unique factorization domains (UFD''s)', 6),
('5', '5.7', 'Extra: Euclidean domains', 7),
('5', '5.8', 'A few harder problems to think about', 8),
('6', '6.1', 'Boundedness', 1),
('6', '6.2', 'Completeness', 2),
('6', '6.3', 'Let the buyer beware', 3),
('6', '6.4', 'Subspaces, and (inb4) a confusing linguistic point', 4),
('6', '6.5', 'A few harder problems to think about', 5),
('7', '7.1', 'Forgetting the metric', 1),
('7', '7.2', 'Re-definitions', 2),
('7', '7.3', 'Hausdorff spaces', 3),
('7', '7.4', 'Subspaces', 4),
('7', '7.5', 'Connected spaces', 5),
('7', '7.6', 'Path-connected spaces', 6),
('7', '7.7', 'Homotopy and simply connected spaces', 7),
('7', '7.8', 'Bases of spaces', 8),
('7', '7.9', 'A few harder problems to think about', 9),
('8', '8.1', 'Definition of sequential compactness', 1),
('8', '8.2', 'Criteria for compactness', 2),
('8', '8.3', 'Compactness using open covers', 3),
('8', '8.4', 'Applications of compactness', 4),
('8', '8.5', '(Optional) Equivalence of formulations of compactness', 5),
('8', '8.6', 'A few harder problems to think about', 6),
('9', '9.1', 'The definitions of a ring and field', 1),
('9', '9.2', 'Modules and vector spaces', 2),
('9', '9.3', 'Direct sums', 3),
('9', '9.4', 'Linear independence, spans, and basis', 4),
('9', '9.5', 'Linear maps', 5),
('9', '9.6', 'What is a matrix?', 6),
('9', '9.7', 'Subspaces and picking convenient bases', 7),
('9', '9.8', 'A cute application: Lagrange interpolation', 8),
('9', '9.9', 'Pedagogical digression: Arrays of numbers are evil', 9),
('9', '9.10', 'A word on general modules', 10),
('9', '9.11', 'A few harder problems to think about', 11),
('10', '10.1', 'Why you should care', 1),
('10', '10.2', 'Warning on assumptions', 2),
('10', '10.3', 'Eigenvectors and eigenvalues', 3),
('10', '10.4', 'The Jordan form', 4),
('10', '10.5', 'Nilpotent maps', 5),
('10', '10.6', 'Reducing to the nilpotent case', 6),
('10', '10.7', '(Optional) Proof of nilpotent Jordan', 7),
('10', '10.8', 'Algebraic and geometric multiplicity', 8),
('10', '10.9', 'A few harder problems to think about', 9),
('11', '11.1', 'Tensor product', 1),
('11', '11.2', 'Dual space', 2),
('11', '11.3', 'VW gives matrices from V to W', 3),
('11', '11.4', 'The trace', 4),
('11', '11.5', 'A few harder problems to think about', 5),
('12', '12.1', 'Wedge product', 1),
('12', '12.2', 'The determinant', 2),
('12', '12.3', 'Characteristic polynomials, and Cayley-Hamilton', 3),
('12', '12.4', 'A few harder problems to think about', 4),
('13', '13.1', 'The inner product', 1),
('13', '13.2', 'Norms', 2),
('13', '13.3', 'Orthogonality', 3),
('13', '13.4', 'Hilbert spaces', 4),
('13', '13.5', 'A few harder problems to think about', 5),
('14', '14.1', 'Synopsis', 1),
('14', '14.2', 'A reminder on Hilbert spaces', 2),
('14', '14.3', 'Common examples', 3),
('14', '14.4', 'Summary, and another teaser', 4),
('14', '14.5', 'Parseval and friends', 5),
('14', '14.6', 'Application: Basel problem', 6),
('14', '14.7', 'Application: Arrow''s Impossibility Theorem', 7),
('14', '14.8', 'A few harder problems to think about', 8),
('15', '15.1', 'Dual of a map', 1),
('15', '15.2', '(Optional) A cautionary tale on what goes wrong if you don''t have a natural isomorphism V .5-.5.5-.5.5-.5.5-.5V', 2),
('15', '15.3', 'Identifying with the dual space', 3),
('15', '15.4', 'The adjoint (conjugate transpose)', 4),
('15', '15.5', 'Eigenvalues of normal maps', 5),
('15', '15.6', 'A few harder problems to think about', 6),
('16', '16.1', 'Definition of a group action', 1),
('16', '16.2', 'Stabilizers and orbits', 2),
('16', '16.3', 'Burnside''s lemma', 3),
('16', '16.4', 'Conjugation of elements', 4),
('16', '16.5', 'A few harder problems to think about', 5),
('17', '17.1', 'Sylow theorems', 1),
('17', '17.2', '(Optional) Proving Sylow''s theorem', 2),
('17', '17.3', '(Optional) Simple groups and Jordan-Hölder', 3),
('17', '17.4', 'A few harder problems to think about', 4),
('18', '18.1', 'Finitely generated abelian groups', 1),
('18', '18.2', 'Some ring theory prerequisites', 2),
('18', '18.3', 'The structure theorem', 3),
('18', '18.4', 'Reduction to maps of free R-modules', 4),
('18', '18.5', 'Uniqueness of primary form', 5),
('18', '18.6', 'Smith normal form', 6),
('18', '18.7', 'A few harder problems to think about', 7),
('19', '19.1', 'Algebras', 1),
('19', '19.2', 'Representations', 2),
('19', '19.3', 'Direct sums', 3),
('19', '19.4', 'Irreducible and indecomposable representations', 4),
('19', '19.5', 'Morphisms of representations', 5),
('19', '19.6', 'The representations of Matd(k)', 6),
('19', '19.7', 'A few harder problems to think about', 7),
('20', '20.1', 'Schur''s lemma continued', 1),
('20', '20.2', 'Density theorem', 2),
('20', '20.3', 'Semisimple algebras', 3),
('20', '20.4', 'Maschke''s theorem', 4),
('20', '20.5', 'Example: the representations of C[S3]', 5),
('20', '20.6', 'A few harder problems to think about', 6),
('21', '21.1', 'Definitions', 1),
('21', '21.2', 'The dual space modulo the commutator', 2),
('21', '21.3', 'Orthogonality of characters', 3),
('21', '21.4', 'Examples of character tables', 4),
('21', '21.5', 'A few harder problems to think about', 5),
('22', '22.1', 'Frobenius divisibility', 1),
('22', '22.2', 'Burnside''s theorem', 2),
('22', '22.3', 'Frobenius determinant', 3),
('23', '23.1', 'Bra-ket notation', 1),
('23', '23.2', 'The state space', 2),
('23', '23.3', 'Observations', 3),
('23', '23.4', 'Entanglement', 4),
('23', '23.5', 'A few harder problems to think about', 5),
('24', '24.1', 'Classical logic gates', 1),
('24', '24.2', 'Reversible classical logic', 2),
('24', '24.3', 'Quantum logic gates', 3),
('24', '24.4', 'Deutsch-Jozsa algorithm', 4),
('24', '24.5', 'A few harder problems to think about', 5),
('25', '25.1', 'The classical (inverse) Fourier transform', 1),
('25', '25.2', 'The quantum Fourier transform', 2),
('25', '25.3', 'Shor''s algorithm', 3),
('26', '26.1', 'Completeness and inf/sup', 1),
('26', '26.2', 'Proofs of the two key completeness properties of R', 2),
('26', '26.3', 'Monotonic sequences', 3),
('26', '26.4', 'Infinite series', 4),
('26', '26.5', 'Series addition is not commutative: a horror story', 5),
('26', '26.6', 'Limits of functions at points', 6),
('26', '26.7', 'Limits of functions at infinity', 7),
('26', '26.8', 'A few harder problems to think about', 8),
('27', '27.1', 'Motivation', 1),
('27', '27.2', 'Algebraic perspective', 2),
('27', '27.3', 'Analytic perspective', 3),
('27', '27.4', 'Mahler coefficients', 4),
('27', '27.5', 'A few harder problems to think about', 5),
('28', '28.1', 'Definition', 1),
('28', '28.2', 'How to compute them', 2),
('28', '28.3', 'Local (and global) maximums', 3),
('28', '28.4', 'Rolle and friends', 4),
('28', '28.5', 'Smooth functions', 5),
('28', '28.6', 'A few harder problems to think about', 6),
('29', '29.1', 'Motivation', 1),
('29', '29.2', 'Power series', 2),
('29', '29.3', 'Differentiating them', 3),
('29', '29.4', 'Analytic functions', 4),
('29', '29.5', 'A definition of Euler''s constant and exponentiation', 5),
('29', '29.6', 'This all works over complex numbers as well, except also complex analysis is heaven', 6),
('29', '29.7', 'A few harder problems to think about', 7),
('30', '30.1', 'Uniform continuity', 1),
('30', '30.2', 'Dense sets and extension', 2),
('30', '30.3', 'Defining the Riemann integral', 3),
('30', '30.4', 'Meshes', 4),
('30', '30.5', 'A few harder problems to think about', 5),
('31', '31.1', 'The nicest functions on earth', 1),
('31', '31.2', 'Complex differentiation', 2),
('31', '31.3', 'Contour integrals', 3),
('31', '31.4', 'Cauchy-Goursat theorem', 4),
('31', '31.5', 'Cauchy''s integral theorem', 5),
('31', '31.6', 'Holomorphic functions are analytic', 6),
('31', '31.7', 'Optional: Proof that holomorphic functions are analytic', 7),
('31', '31.8', 'A few harder problems to think about', 8),
('32', '32.1', 'The second nicest functions on earth', 1),
('32', '32.2', 'Meromorphic functions', 2),
('32', '32.3', 'Winding numbers and the residue theorem', 3),
('32', '32.4', 'Argument principle', 4),
('32', '32.5', 'Digression: the Argument Principle viewed geometrically', 5),
('32', '32.6', 'Philosophy: why are holomorphic functions so nice?', 6),
('32', '32.7', 'A few harder problems to think about', 7),
('33', '33.1', 'Motivation: square root of a complex number', 1),
('33', '33.2', 'Square roots of holomorphic functions', 2),
('33', '33.3', 'Covering projections', 3),
('33', '33.4', 'Complex logarithms', 4),
('33', '33.5', 'Some special cases', 5),
('33', '33.6', 'A few harder problems to think about', 6),
('34', '34.1', 'The Game Plan', 1),
('34', '34.2', 'Step 1: The Simplest Case', 2),
('34', '34.3', 'Step 2: Nested Roots', 3),
('34', '34.4', 'Step 3: Normal Groups', 4),
('34', '34.5', 'Summary', 5),
('34', '34.6', 'A few harder problems to think about', 6),
('35', '35.1', 'Letter connotations', 1),
('35', '35.2', 'Motivating measure spaces via random variables', 2),
('35', '35.3', 'Motivating measure spaces geometrically', 3),
('35', '35.4', 'σ-algebras and measurable spaces', 4),
('35', '35.5', 'Measure spaces', 5),
('35', '35.6', 'A hint of Banach-Tarski', 6),
('35', '35.7', 'Measurable functions', 7),
('35', '35.8', 'On the word ``almost''''', 8),
('35', '35.9', 'A few harder problems to think about', 9),
('36', '36.1', 'Pre-measures', 1),
('36', '36.2', 'Outer measures', 2),
('36', '36.3', 'Carathéodory extension for outer measures', 3),
('36', '36.4', 'Defining the Lebesgue measure', 4),
('36', '36.5', 'A fourth row: Carathéodory for pre-measures', 5),
('36', '36.6', 'From now on, we assume the Borel measure', 6),
('36', '36.7', 'A few harder problems to think about', 7),
('37', '37.1', 'The definition', 1),
('37', '37.2', 'An equivalent definition', 2),
('37', '37.3', 'Relation to Riemann integrals (or: actually computing Lebesgue integrals)', 3),
('37', '37.4', 'A few harder problems to think about', 4),
('38', '38.1', 'Motivating limit interchange', 1),
('38', '38.2', 'Overview', 2),
('38', '38.3', 'Fatou''s lemma', 3),
('38', '38.4', 'Everything else', 4),
('38', '38.5', 'Fubini and Tonelli', 5),
('38', '38.6', 'A few harder problems to think about', 6),
('39', '39.1', 'LCA groups', 1),
('39', '39.2', 'The Pontryagin dual', 2),
('39', '39.3', 'The orthonormal basis in the compact case', 3),
('39', '39.4', 'The Fourier transform of the non-compact case', 4),
('39', '39.5', 'Summary', 5),
('39', '39.6', 'A few harder problems to think about', 6),
('40', '40.1', 'Random variables', 1),
('40', '40.2', 'Distribution functions', 2),
('40', '40.3', 'Examples of random variables', 3),
('40', '40.4', 'Characteristic functions', 4),
('40', '40.5', 'Independent random variables', 5),
('40', '40.6', 'A few harder problems to think about', 6),
('41', '41.1', 'Notions of convergence', 1),
('41', '41.2', 'Weak law of large numbers', 2),
('41', '41.3', 'Strong law of large numbers', 3),
('41', '41.4', 'A few harder problems to think about', 4),
('42', '42.1', 'How to make money almost surely', 1),
('42', '42.2', 'Sub-σ-algebras and filtrations', 2),
('42', '42.3', 'Conditional expectation', 3),
('42', '42.4', 'Supermartingales', 4),
('42', '42.5', 'Optional stopping', 5),
('42', '42.6', 'Fun applications of optional stopping (TO DO)', 6),
('42', '42.7', 'A few harder problems to think about', 7),
('43', '43.1', 'The total derivative', 1),
('43', '43.2', 'The projection principle', 2),
('43', '43.3', 'Total and partial derivatives', 3),
('43', '43.4', '(Optional) A word on higher derivatives', 4),
('43', '43.5', 'Towards differential forms', 5),
('43', '43.6', 'A few harder problems to think about', 6),
('44', '44.1', 'Pictures of differential forms', 1),
('44', '44.2', 'Pictures of exterior derivatives', 2),
('44', '44.3', 'Differential forms', 3),
('44', '44.4', 'Exterior derivatives', 4),
('44', '44.5', 'Digression: k(V) versus (k(V))', 5),
('44', '44.6', 'Tangential remark: Arc length ds is not a 1-form', 6),
('44', '44.7', 'Closed and exact forms', 7),
('44', '44.8', 'A few harder problems to think about', 8),
('45', '45.1', 'Motivation: line integrals', 1),
('45', '45.2', 'Pullbacks', 2),
('45', '45.3', 'Cells', 3),
('45', '45.4', 'Boundaries', 4),
('45', '45.5', 'Stokes'' theorem', 5),
('45', '45.6', 'Back to Earth: A comparison to what you learned in vector calculus', 6),
('45', '45.7', 'A few harder problems to think about', 7),
('46', '46.1', 'Topological manifolds', 1),
('46', '46.2', 'Smooth manifolds', 2),
('46', '46.3', 'Regular value theorem', 3),
('46', '46.4', 'Differential forms on manifolds', 4),
('46', '46.5', 'Orientations', 5),
('46', '46.6', 'Stokes'' theorem for manifolds', 6),
('46', '46.7', '(Optional) The tangent and cotangent space', 7),
('46', '46.8', 'A few harder problems to think about', 8),
('47', '47.1', 'Complex structures', 1),
('47', '47.2', 'Riemann surface', 2),
('47', '47.3', 'Complex manifold', 3),
('47', '47.4', 'Examples of Riemann surfaces', 4),
('48', '48.1', 'Definition', 1),
('48', '48.2', 'Functions to the Riemann sphere', 2),
('48', '48.3', 'Some other nice properties', 3),
('48', '48.4', 'Multiplicity of a map', 4),
('48', '48.5', 'The sum of the orders of a meromorphic function', 5),
('48', '48.6', 'The Hurwitz formula', 6),
('48', '48.7', 'The identity theorem', 7),
('49', '49.1', 'Affine plane curves', 1),
('49', '49.2', 'The projective line CP1', 2),
('49', '49.3', 'Projective plane curves', 3),
('49', '49.4', 'Filling in the holes', 4),
('49', '49.5', 'Nodes of a plane curve', 5),
('50', '50.1', 'Differential form on C', 1),
('50', '50.2', 'Visualization of differential forms', 2),
('51', '51.1', 'Motivation', 1),
('51', '51.2', 'Divisors', 2),
('51', '51.3', 'Degree of a divisor', 3),
('51', '51.4', 'The principal divisor of a meromorphic function', 4),
('51', '51.5', 'The Riemann-Roch theorem', 5),
('52', '52.1', 'Overview', 1),
('52', '52.2', 'Definition', 2),
('52', '52.3', 'Visualizing a line bundle', 3),
('52', '52.4', 'Morphisms between line bundles', 4),
('52', '52.5', 'Relation to invertible sheaves', 5),
('53', '53.1', 'Motivation from high school algebra', 1),
('53', '53.2', 'Algebraic numbers and algebraic integers', 2),
('53', '53.3', 'Number fields', 3),
('53', '53.4', 'Primitive element theorem, and monogenic extensions', 4),
('53', '53.5', 'A few harder problems to think about', 5),
('54', '54.1', 'Norms and traces', 1),
('54', '54.2', 'The ring of integers', 2),
('54', '54.3', 'On monogenic extensions', 3),
('54', '54.4', 'A few harder problems to think about', 4),
('55', '55.1', 'Motivation', 1),
('55', '55.2', 'Ideal arithmetic', 2),
('55', '55.3', 'Dedekind domains', 3),
('55', '55.4', 'Unique factorization works', 4),
('55', '55.5', 'The factoring algorithm', 5),
('55', '55.6', 'Fractional ideals', 6),
('55', '55.7', 'The ideal norm', 7),
('55', '55.8', 'A few harder problems to think about', 8),
('56', '56.1', 'The class group', 1),
('56', '56.2', 'The discriminant of a number field', 2),
('56', '56.3', 'The signature of a number field', 3),
('56', '56.4', 'Minkowski''s theorem', 4),
('56', '56.5', 'The trap box', 5),
('56', '56.6', 'The Minkowski bound', 6),
('56', '56.7', 'The class group is finite', 7),
('56', '56.8', 'Computation of class numbers', 8),
('56', '56.9', 'Optional: Proof that OK is a free Z-module', 9),
('56', '56.10', 'A few harder problems to think about', 10),
('57', '57.1', 'A few harder problems to think about', 1),
('58', '58.1', 'Units', 1),
('58', '58.2', 'Dirichlet''s unit theorem', 2),
('58', '58.3', 'Finding fundamental units', 3),
('58', '58.4', 'Pell''s equation', 4),
('58', '58.5', 'A few harder problems to think about', 5),
('59', '59.1', 'Motivation', 1),
('59', '59.2', 'Field extensions, algebraic extension, and splitting fields', 2),
('59', '59.3', 'Embeddings into algebraic closures for number fields', 3),
('59', '59.4', 'Everyone hates characteristic 2: separable vs irreducible', 4),
('59', '59.5', 'Automorphism groups and Galois extensions', 5),
('59', '59.6', 'Fundamental theorem of Galois theory', 6),
('59', '59.7', 'A few harder problems to think about', 7),
('59', '59.8', '(Optional) Proof that Galois extensions are splitting', 8),
('60', '60.1', 'Example of a finite field', 1),
('60', '60.2', 'Finite fields have prime power order', 2),
('60', '60.3', 'All finite fields are isomorphic', 3),
('60', '60.4', 'The Galois theory of finite fields', 4),
('60', '60.5', 'Extra: The multiplicative group of a finite field', 5),
('60', '60.6', 'A few harder problems to think about', 6),
('61', '61.1', 'Ramified / inert / split primes', 1),
('61', '61.2', 'Primes ramify if and only if they divide K', 2),
('61', '61.3', 'Inertial degrees', 3),
('61', '61.4', 'The magic of Galois extensions', 4),
('61', '61.5', '(Optional) Decomposition and inertia groups', 5),
('61', '61.6', 'Tangential remark: more general Galois extensions', 6),
('61', '61.7', 'A few harder problems to think about', 7),
('62', '62.1', 'Frobenius elements', 1),
('62', '62.2', 'Conjugacy classes', 2),
('62', '62.3', 'Chebotarev density theorem', 3),
('62', '62.4', 'Example: Frobenius elements of cyclotomic fields', 4),
('62', '62.5', 'Frobenius elements behave well with restriction', 5),
('62', '62.6', 'Application: Quadratic reciprocity', 6),
('62', '62.7', 'Frobenius elements control factorization', 7),
('62', '62.8', 'Example application: IMO 2003 problem 6', 8),
('62', '62.9', 'A few harder problems to think about', 9),
('63', '63.1', 'Overview', 1),
('63', '63.2', 'Infinite primes', 2),
('63', '63.3', 'Modular arithmetic with infinite primes', 3),
('63', '63.4', 'Infinite primes in extensions', 4),
('63', '63.5', 'Frobenius element and Artin symbol', 5),
('63', '63.6', 'Artin reciprocity', 6),
('63', '63.7', 'Application: Generalization of sum of two squares', 7),
('63', '63.8', 'A few harder problems to think about', 8),
('64', '64.1', 'Spheres', 1),
('64', '64.2', 'Quotient topology', 2),
('64', '64.3', 'Product topology', 3),
('64', '64.4', 'Disjoint union and wedge sum', 4),
('64', '64.5', 'CW complexes', 5),
('64', '64.6', 'The torus, Klein bottle, RPn, CPn', 6),
('64', '64.7', 'A few harder problems to think about', 7),
('65', '65.1', 'Fusing paths together', 1),
('65', '65.2', 'Fundamental groups', 2),
('65', '65.3', 'Fundamental groups are invariant under homeomorphism', 3),
('65', '65.4', 'Higher homotopy groups', 4),
('65', '65.5', 'Homotopy equivalent spaces', 5),
('65', '65.6', 'The pointed homotopy category', 6),
('65', '65.7', 'A few harder problems to think about', 7),
('66', '66.1', 'Even coverings and covering projections', 1),
('66', '66.2', 'Lifting theorem', 2),
('66', '66.3', 'Lifting correspondence', 3),
('66', '66.4', 'Regular coverings', 4),
('66', '66.5', 'The algebra of fundamental groups', 5),
('66', '66.6', 'A few harder problems to think about', 6),
('67', '67.1', 'Motivation: isomorphisms', 1),
('67', '67.2', 'Categories, and examples thereof', 2),
('67', '67.3', 'Special objects in categories', 3),
('67', '67.4', 'Binary products', 4),
('67', '67.5', 'Monic and epic maps', 5),
('67', '67.6', 'A few harder problems to think about', 6),
('68', '68.1', 'Many examples of functors', 1),
('68', '68.2', 'Covariant functors', 2),
('68', '68.3', 'Covariant functors as indexed family of objects', 3),
('68', '68.4', 'Contravariant functors', 4),
('68', '68.5', 'Equivalence of categories', 5),
('68', '68.6', '(Optional) Natural transformations', 6),
('68', '68.7', '(Optional) The Yoneda lemma', 7),
('68', '68.8', 'A few harder problems to think about', 8),
('69', '69.1', 'Equalizers', 1),
('69', '69.2', 'Pullback squares (TO DO)', 2),
('69', '69.3', 'Limits', 3),
('69', '69.4', 'A few harder problems to think about', 4),
('70', '70.1', 'Zero objects, kernels, cokernels, and images', 1),
('70', '70.2', 'Additive and abelian categories', 2),
('70', '70.3', 'Exact sequences', 3),
('70', '70.4', 'The Freyd-Mitchell embedding theorem', 4),
('70', '70.5', 'Breaking long exact sequences', 5),
('70', '70.6', 'A few harder problems to think about', 6),
('71', '71.1', 'Simplices and boundaries', 1),
('71', '71.2', 'The singular homology groups', 2),
('71', '71.3', 'The homology functor and chain complexes', 3),
('71', '71.4', 'More examples of chain complexes', 4),
('71', '71.5', 'A few harder problems to think about', 5),
('72', '72.1', 'Short exact sequences and four examples', 1),
('72', '72.2', 'The long exact sequence of homology groups', 2),
('72', '72.3', 'The Mayer-Vietoris sequence', 3),
('72', '72.4', 'A few harder problems to think about', 4),
('73', '73.1', 'Motivation', 1),
('73', '73.2', 'The long exact sequences', 2),
('73', '73.3', 'The category of pairs', 3),
('73', '73.4', 'Excision', 4),
('73', '73.5', 'Some applications', 5),
('73', '73.6', 'Invariance of dimension', 6),
('73', '73.7', 'A few harder problems to think about', 7),
('74', '74.1', 'Degrees', 1),
('74', '74.2', 'Cellular chain complex', 2),
('74', '74.3', 'Digression: why are the homology groups equal?', 3),
('74', '74.4', 'Application: Euler characteristic via Betti numbers', 4),
('74', '74.5', 'The cellular boundary formula', 5),
('74', '74.6', 'A few harder problems to think about', 6),
('75', '75.1', 'Cochain complexes', 1),
('75', '75.2', 'Cohomology of spaces', 2),
('75', '75.3', 'Cohomology of spaces is functorial', 3),
('75', '75.4', 'Universal coefficient theorem', 4),
('75', '75.5', 'Explanation for universal coefficient theorem', 5),
('75', '75.6', 'Example computation of cohomology groups', 6),
('75', '75.7', 'Visualization of cohomology groups', 7),
('75', '75.8', 'Relative cohomology groups', 8),
('75', '75.9', 'A few harder problems to think about', 9),
('76', '76.1', 'Poincaré duality', 1),
('76', '76.2', 'de Rham cohomology', 2),
('76', '76.3', 'Graded rings', 3),
('76', '76.4', 'Cup products', 4),
('76', '76.5', 'Relative cohomology pseudo-rings', 5),
('76', '76.6', 'Wedge sums', 6),
('76', '76.7', 'Cross product', 7),
('76', '76.8', 'Künneth formula', 8),
('76', '76.9', 'A few harder problems to think about', 9),
('77', '77.1', 'Affine varieties', 1),
('77', '77.2', 'Naming affine varieties via ideals', 2),
('77', '77.3', 'Radical ideals and Hilbert''s Nullstellensatz', 3),
('77', '77.4', 'Pictures of varieties in A1', 4),
('77', '77.5', 'Prime ideals correspond to irreducible affine varieties', 5),
('77', '77.6', 'Pictures in A2 and A3', 6),
('77', '77.7', 'Maximal ideals', 7),
('77', '77.8', 'Motivating schemes with non-radical ideals', 8),
('77', '77.9', 'A few harder problems to think about', 9),
('78', '78.1', 'Synopsis', 1),
('78', '78.2', 'The Zariski topology on An', 2),
('78', '78.3', 'The Zariski topology on affine varieties', 3),
('78', '78.4', 'Coordinate rings', 4),
('78', '78.5', 'The sheaf of regular functions', 5),
('78', '78.6', 'Regular functions on distinguished open sets', 6),
('78', '78.7', 'Baby ringed spaces', 7),
('78', '78.8', 'A few harder problems to think about', 8),
('79', '79.1', 'Graded rings', 1),
('79', '79.2', 'The ambient space', 2),
('79', '79.3', 'Homogeneous ideals', 3),
('79', '79.4', 'As ringed spaces', 4),
('79', '79.5', 'Examples of regular functions', 5),
('79', '79.6', 'A few harder problems to think about', 6),
('80', '80.1', 'Non-radical ideals', 1),
('80', '80.2', 'Hilbert functions of finitely many points', 2),
('80', '80.3', 'Hilbert polynomials', 3),
('80', '80.4', 'Bézout''s theorem', 4),
('80', '80.5', 'Applications', 5),
('80', '80.6', 'A few harder problems to think about', 6),
('81', '81.1', 'Defining morphisms of baby ringed spaces', 1),
('81', '81.2', 'Classifying the simplest examples', 2),
('81', '81.3', 'Some more applications and examples', 3),
('81', '81.4', 'The hyperbola effect', 4),
('81', '81.5', 'A few harder problems to think about', 5),
('82', '82.1', 'Motivation and warnings', 1),
('82', '82.2', 'Pre-sheaves', 2),
('82', '82.3', 'Stalks and germs', 3),
('82', '82.4', 'Sheaves', 4),
('82', '82.5', 'For sheaves, sections ``are'''' sequences of germs', 5),
('82', '82.6', 'Sheafification (optional)', 6),
('82', '82.7', 'A few harder problems to think about', 7),
('83', '83.1', 'Spoilers', 1),
('83', '83.2', 'The definition', 2),
('83', '83.3', 'Localization away from an element', 3),
('83', '83.4', 'Localization at a prime ideal', 4),
('83', '83.5', 'Prime ideals of localizations', 5),
('83', '83.6', 'Prime ideals of quotients', 6),
('83', '83.7', 'Localization commutes with quotients', 7),
('83', '83.8', 'A few harder problems to think about', 8),
('84', '84.1', 'Some more advertising', 1),
('84', '84.2', 'The set of points', 2),
('84', '84.3', 'The Zariski topology on the spectrum', 3),
('84', '84.4', 'Krull dimension', 4),
('84', '84.5', 'On radicals', 5),
('84', '84.6', 'A few harder problems to think about', 6),
('85', '85.1', 'A useless definition of the structure sheaf', 1),
('85', '85.2', 'The value of distinguished open sets (or: how to actually compute sections)', 2),
('85', '85.3', 'The stalks of the structure sheaf', 3),
('85', '85.4', 'Local rings and residue fields: linking germs to values', 4),
('85', '85.5', 'Recap', 5),
('85', '85.6', 'Functions are determined by germs, not values', 6),
('85', '85.7', 'A few harder problems to think about', 7),
('86', '86.1', 'Example: Spec k, a single point', 1),
('86', '86.2', 'Spec C[x], a one-dimensional line', 2),
('86', '86.3', 'Spec R[x], a one-dimensional line with complex conjugates glued (no fear nullstellensatz)', 3),
('86', '86.4', 'Spec k[x], over any ground field', 4),
('86', '86.5', 'Spec Z, a one-dimensional scheme', 5),
('86', '86.6', 'Spec k[x] / (x2-7x+12), two points', 6),
('86', '86.7', 'Spec k[x]/(x2), the double point', 7),
('86', '86.8', 'Spec k[x]/(x3-5x2), a double point and a single point', 8),
('86', '86.9', 'Spec Z/60Z, a scheme with three points', 9),
('86', '86.10', 'Spec k[x,y], the two-dimensional plane', 10),
('86', '86.11', 'Spec Z[x], a two-dimensional scheme, and Mumford''s picture', 11),
('86', '86.12', 'Spec k[x,y]/(y-x2), the parabola', 12),
('86', '86.13', 'Spec Z[i], the Gaussian integers (one-dimensional)', 13),
('86', '86.14', 'Long example: Spec k[x,y]/(xy), two axes', 14),
('86', '86.15', 'Spec k[x,x-1], the punctured line (or hyperbola)', 15),
('86', '86.16', 'Spec k[x](x), zooming in to the origin of the line', 16),
('86', '86.17', 'Spec k[x,y](x,y), zooming in to the origin of the plane', 17),
('86', '86.18', 'Spec k[x,y](0) = Spec k(x,y), the stalk above the generic point', 18),
('86', '86.19', 'A few harder problems to think about', 19),
('87', '87.1', 'Morphisms of ringed spaces via sections', 1),
('87', '87.2', 'Morphisms of ringed spaces via stalks', 2),
('87', '87.3', 'Morphisms of locally ringed spaces', 3),
('87', '87.4', 'A few examples of morphisms between affine schemes', 4),
('87', '87.5', 'The big theorem', 5),
('87', '87.6', 'More examples of scheme morphisms', 6),
('87', '87.7', 'A little bit on non-affine schemes', 7),
('87', '87.8', 'Where to go from here', 8),
('87', '87.9', 'A few harder problems to think about', 9),
('88', '88.1', 'Let''s construct a monster', 1),
('88', '88.2', 'Review of finite induction', 2),
('88', '88.3', 'Transfinite induction', 3),
('88', '88.4', 'Wrapping up functional equations', 4),
('88', '88.5', 'Zorn''s lemma', 5),
('88', '88.6', 'A few harder problems to think about', 6),
('89', '89.1', 'The ultimate functional equation', 1),
('89', '89.2', 'Cantor''s paradox', 2),
('89', '89.3', 'The language of set theory', 3),
('89', '89.4', 'The axioms of ZFC', 4),
('89', '89.5', 'Encoding', 5),
('89', '89.6', 'Choice and well-ordering', 6),
('89', '89.7', 'Sets vs classes', 7),
('89', '89.8', 'A few harder problems to think about', 8),
('90', '90.1', 'Counting for preschoolers', 1),
('90', '90.2', 'Counting for set theorists', 2),
('90', '90.3', 'Definition of an ordinal', 3),
('90', '90.4', 'Ordinals are ``tall''''', 4),
('90', '90.5', 'Transfinite induction and recursion', 5),
('90', '90.6', 'Ordinal arithmetic', 6),
('90', '90.7', 'The hierarchy of sets', 7),
('90', '90.8', 'A few harder problems to think about', 8),
('91', '91.1', 'Equinumerous sets and cardinals', 1),
('91', '91.2', 'Cardinalities', 2),
('91', '91.3', 'Aleph numbers', 3),
('91', '91.4', 'Cardinal arithmetic', 4),
('91', '91.5', 'Cardinal exponentiation', 5),
('91', '91.6', 'Cofinality', 6),
('91', '91.7', 'Inaccessible cardinals', 7),
('91', '91.8', 'A few harder problems to think about', 8),
('92', '92.1', 'Models', 1),
('92', '92.2', 'Sentences and satisfaction', 2),
('92', '92.3', 'The Levy hierarchy', 3),
('92', '92.4', 'Substructures, and Tarski-Vaught', 4),
('92', '92.5', 'Obtaining the axioms of ZFC', 5),
('92', '92.6', 'Mostowski collapse', 6),
('92', '92.7', 'Adding an inaccessible', 7),
('92', '92.8', 'FAQ''s on countable models', 8),
('92', '92.9', 'Picturing inner models', 9),
('92', '92.10', 'A few harder problems to think about', 10),
('93', '93.1', 'Setting up posets', 1),
('93', '93.2', 'More properties of posets', 2),
('93', '93.3', 'Names, and the generic extension', 3),
('93', '93.4', 'Fundamental theorem of forcing', 4),
('93', '93.5', '(Optional) Defining the relation', 5),
('93', '93.6', 'The remaining axioms', 6),
('93', '93.7', 'A few harder problems to think about', 7),
('94', '94.1', 'Adding in reals', 1),
('94', '94.2', 'The countable chain condition', 2),
('94', '94.3', 'Preserving cardinals', 3),
('94', '94.4', 'Infinite combinatorics', 4),
('94', '94.5', 'A few harder problems to think about', 5),
('A', 'A.1', 'Basic algebra and topology', 1),
('A', 'A.2', 'Second-year topics', 2),
('A', 'A.3', 'Advanced topics', 3),
('A', 'A.4', 'Topics not in Napkin', 4),
('D', 'D.1', 'General', 1),
('D', 'D.2', 'Functions and sets', 2),
('D', 'D.3', 'Abstract and linear algebra', 3),
('D', 'D.4', 'Quantum computation', 4),
('D', 'D.5', 'Topology and real/complex analysis', 5),
('D', 'D.6', 'Measure theory and probability', 6),
('D', 'D.7', 'Algebraic topology', 7),
('D', 'D.8', 'Category theory', 8),
('D', 'D.9', 'Differential geometry', 9),
('D', 'D.10', 'Algebraic number theory', 10),
('D', 'D.11', 'Representation theory', 11),
('D', 'D.12', 'Algebraic geometry', 12),
('D', 'D.13', 'Set theory', 13),
('E', 'E.1', 'Sets', 1),
('E', 'E.2', 'Functions', 2),
('E', 'E.3', 'Equivalence relations', 3)
) AS v(chapter, key, title, ord) JOIN "math"."chapters" c ON c.key = v.chapter;
--> statement-breakpoint
-- Progress bookkeeping, so a plain UPDATE from a bot or psql behaves like the site:
-- status changes stamp started_at/completed_at, and every status, exercise, note and section
-- change is logged to math.activity (source from the transaction-local math.source setting,
-- 'sql' when unset; math.apply_progress sets it).
CREATE FUNCTION "math"."chapters_stamp"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
IF NEW.status = 'not_started' THEN
NEW.started_at := NULL;
NEW.completed_at := NULL;
ELSE
IF NEW.status IN ('reading', 'exercises', 'done') AND NEW.started_at IS NULL THEN
NEW.started_at := now();
END IF;
IF NEW.status = 'done' THEN
NEW.completed_at := coalesce(NEW.completed_at, now());
ELSE
NEW.completed_at := NULL;
END IF;
END IF;
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
--> statement-breakpoint
CREATE FUNCTION "math"."chapters_log"() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
src text := coalesce(nullif(current_setting('math.source', true), ''), 'sql');
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
INSERT INTO math.activity (chapter_id, kind, from_value, to_value, source)
VALUES (NEW.id, 'status', OLD.status, NEW.status, src);
END IF;
IF NEW.exercises_done IS DISTINCT FROM OLD.exercises_done THEN
INSERT INTO math.activity (chapter_id, kind, from_value, to_value, delta, source)
VALUES (NEW.id, 'exercises', OLD.exercises_done::text, NEW.exercises_done::text,
NEW.exercises_done - OLD.exercises_done, src);
END IF;
IF coalesce(NEW.notes, '') IS DISTINCT FROM coalesce(OLD.notes, '') THEN
INSERT INTO math.activity (chapter_id, kind, to_value, source)
VALUES (NEW.id, 'note', left(NEW.notes, 200), src);
END IF;
RETURN NULL;
END $$;
--> statement-breakpoint
CREATE FUNCTION "math"."sections_log"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.done_at IS NULL) IS DISTINCT FROM (OLD.done_at IS NULL) THEN
INSERT INTO math.activity (chapter_id, section_id, kind, to_value, source)
VALUES (NEW.chapter_id, NEW.id, 'section', CASE WHEN NEW.done_at IS NULL THEN 'undone' ELSE 'done' END,
coalesce(nullif(current_setting('math.source', true), ''), 'sql'));
END IF;
RETURN NULL;
END $$;
--> statement-breakpoint
CREATE TRIGGER "chapters_stamp" BEFORE UPDATE ON "math"."chapters"
FOR EACH ROW EXECUTE FUNCTION "math"."chapters_stamp"();
--> statement-breakpoint
CREATE TRIGGER "chapters_log" AFTER UPDATE ON "math"."chapters"
FOR EACH ROW EXECUTE FUNCTION "math"."chapters_log"();
--> statement-breakpoint
CREATE TRIGGER "sections_log" AFTER UPDATE OF "done_at" ON "math"."sections"
FOR EACH ROW EXECUTE FUNCTION "math"."sections_log"();
--> statement-breakpoint
-- One progress update as JSON (or an array of them), the format bots send:
-- {"chapter": "3", "status": "reading" | "exercises" | "done" | "skipped" | "not_started",
-- "exercises_done": 4 (absolute) or "exercises_add": 1 (relative),
-- "notes": "short note" ("" clears),
-- "sections_done": ["3.1", "3.2"] or "all", "sections_undone": [...] or "all",
-- "source": "bot"}
-- Every field but "chapter" is optional. Returns the updated chapter(s) as JSON.
CREATE FUNCTION "math"."apply_progress"(p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
DECLARE
ch_id integer;
k text;
item jsonb;
res jsonb := '[]'::jsonb;
BEGIN
IF jsonb_typeof(p) = 'array' THEN
FOR item IN SELECT * FROM jsonb_array_elements(p) LOOP
res := res || jsonb_build_array(math.apply_progress(item));
END LOOP;
RETURN res;
END IF;
k := btrim(p->>'chapter');
SELECT id INTO ch_id FROM math.chapters WHERE key = upper(k);
IF ch_id IS NULL THEN
RAISE EXCEPTION 'math.apply_progress: unknown chapter %', coalesce(k, '(missing)');
END IF;
PERFORM set_config('math.source', coalesce(nullif(p->>'source', ''), 'bot'), true);
UPDATE math.chapters SET
status = coalesce(nullif(p->>'status', ''), status),
exercises_done = CASE
WHEN p ? 'exercises_done' THEN greatest(0, (p->>'exercises_done')::int)
WHEN p ? 'exercises_add' THEN greatest(0, exercises_done + (p->>'exercises_add')::int)
ELSE exercises_done END,
notes = CASE WHEN p ? 'notes' THEN nullif(btrim(p->>'notes'), '') ELSE notes END
WHERE id = ch_id;
IF p ? 'sections_done' THEN
UPDATE math.sections SET done_at = now()
WHERE chapter_id = ch_id AND done_at IS NULL
AND (p->'sections_done' = '"all"'::jsonb
OR (jsonb_typeof(p->'sections_done') = 'array'
AND key IN (SELECT jsonb_array_elements_text(p->'sections_done'))));
END IF;
IF p ? 'sections_undone' THEN
UPDATE math.sections SET done_at = NULL
WHERE chapter_id = ch_id AND done_at IS NOT NULL
AND (p->'sections_undone' = '"all"'::jsonb
OR (jsonb_typeof(p->'sections_undone') = 'array'
AND key IN (SELECT jsonb_array_elements_text(p->'sections_undone'))));
END IF;
SELECT jsonb_build_object(
'chapter', c.key, 'title', c.title, 'status', c.status,
'exercises_done', c.exercises_done, 'exercise_count', c.exercise_count,
'started_at', c.started_at, 'completed_at', c.completed_at,
'sections_done', (SELECT count(*) FROM math.sections s WHERE s.chapter_id = c.id AND s.done_at IS NOT NULL),
'sections', (SELECT count(*) FROM math.sections s WHERE s.chapter_id = c.id))
INTO item FROM math.chapters c WHERE c.id = ch_id;
RETURN item;
END $$;
--> statement-breakpoint
-- Privacy: private by default (owner-only on the site, MATH_PRIVATE in #lib/math). Like notes
-- and tasks, only the app role can read or write (no personal_ro). Grants only run on prod
-- (migrations as `personal`).
REVOKE ALL ON SCHEMA math FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA math TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA math TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA math TO personal_app;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA math TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA math
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA math
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

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

399
scripts/cs-history.ts Normal file
View 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
View File

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

View File

@@ -188,8 +188,9 @@ try {
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
const AI_TABLES = 3;
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
const AI_TABLES = 5;
const [ai] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
@@ -286,6 +287,33 @@ try {
notes.public_usage
)
throw new Error(`notes schema tables/grants are wrong: ${JSON.stringify(notes)}`);
// Math (the Napkin tracker, private by default): app role read/write, personal_ro none.
// 4 = parts, chapters, sections, activity; the migration seeds all 100 chapters.
const MATH_TABLES = 4;
const [math] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'math', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'math' and c.relkind = 'r'
`;
if (
Number(math.tables) !== MATH_TABLES ||
Number(math.app_rw) !== MATH_TABLES ||
Number(math.ro_read) !== 0 ||
math.public_usage
)
throw new Error(`math schema tables/grants are wrong: ${JSON.stringify(math)}`);
const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`;
if (mathChapters !== 100)
throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`);
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -295,7 +323,7 @@ try {
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

View File

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

View File

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

View File

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

View File

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

View File

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

39
src/lib/math.ts Normal file
View File

@@ -0,0 +1,39 @@
// Math tracker (/trackers/math): Evan Chen's "An Infinitely Large Napkin". Shared by the
// server (owner gate, loads) and the page.
/**
* The privacy switch. true: owner-only (404 for everyone else, lock icon in every tracker
* list). false: a public, read-only page for everyone else (notes stay owner-only, edits
* are always owner-only). Flip this one constant; owner.ts and projects.ts read it.
*/
export const MATH_PRIVATE = true;
export const MATH_STATUSES = ['not_started', 'reading', 'exercises', 'done', 'skipped'] as const;
export type MathStatus = (typeof MATH_STATUSES)[number];
export const STATUS_LABEL: Record<MathStatus, string> = {
not_started: 'not started',
reading: 'reading',
exercises: 'exercises',
done: 'done',
skipped: 'skipped'
};
/** The free PDF of the book; chapters link to it with #page=N (PDF page numbers). */
export const NAPKIN_PDF = 'https://venhance.github.io/napkin/Napkin.pdf';
export const pdfPage = (page: number) => `${NAPKIN_PDF}#page=${page}`;
/** "9,10,11,12,18" → "9–12, 18" (numeric keys only; letters are listed as-is). */
export function compactKeys(keys: readonly string[]): string {
const nums = keys.filter((k) => /^\d+$/.test(k)).map(Number);
const other = keys.filter((k) => !/^\d+$/.test(k));
nums.sort((a, b) => a - b);
const out: string[] = [];
for (let i = 0; i < nums.length;) {
let j = i;
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
out.push(j - i >= 2 ? `${nums[i]}–${nums[j]}` : nums.slice(i, j + 1).join(', '));
i = j + 1;
}
return [...out, ...other].join(', ');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { MATH_PRIVATE } from './math';
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
export const projects = [
{
@@ -386,6 +387,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false,
private: true
},
{
name: 'math',
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",
href: '/trackers/math',
external: false,
private: MATH_PRIVATE
},
{
name: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations',

217
src/lib/server/ai-setup.ts Normal file
View 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 };
}

View File

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

View File

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

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

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

View File

@@ -1,5 +1,13 @@
import { sql } from 'drizzle-orm';
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
import {
check,
date,
doublePrecision,
index,
integer,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { aiSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
]
);
/**
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
* like a secret, and nothing here is ever a key value.
*/
export const AI_SETUP_SECTIONS = [
'connections',
'skills',
'instructions',
'plugins',
'routines',
'agents',
'plan',
'notes'
] as const;
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
export const AI_SETUP_COLORS = [
'orange',
'green',
'blue',
'violet',
'pink',
'sky',
'amber',
'zinc'
] as const;
export const aiSetupProviders = aiSchema.table(
'provider_setup_providers',
{
id: id(),
name: text('name').notNull(),
/** The products this card covers, e.g. "claude.ai + Claude Code". */
products: text('products'),
color: text('color').notNull().default('zinc'),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
uniqueIndex('provider_setup_providers_name_key').on(t.name),
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
check(
'provider_setup_providers_color_check',
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
)
]
);
export const aiSetupEntries = aiSchema.table(
'provider_setup_entries',
{
id: id(),
providerId: integer('provider_id')
.notNull()
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
section: text('section').notNull(),
name: text('name').notNull(),
note: text('note'),
scope: text('scope'),
link: text('link'),
/** Free markdown text (custom instructions, longer notes). */
body: text('body'),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
check(
'provider_setup_entries_section_check',
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
),
check(
'provider_setup_entries_scope_check',
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
),
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
]
);

View File

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

View File

@@ -33,3 +33,4 @@ export * from './movies';
export * from './worldtodo';
export * from './osu-goals';
export * from './notes';
export * from './math';

View File

@@ -0,0 +1,112 @@
import { sql } from 'drizzle-orm';
import { check, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { mathSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Math tracker (/trackers/math): working through Evan Chen's "An Infinitely Large Napkin".
// Parts and chapters (with sections) are seeded from the book's outline in the migration.
// Progress lives on the chapter rows; triggers (in the migration) keep started_at/completed_at
// and log every status / exercise / note / section change to `activity`. Bots and scripts
// update progress with `select math.apply_progress('{"chapter":"3","status":"done"}')` or a
// plain UPDATE (both are logged). Prereqs are hints only, never locks.
// Statuses (kept in sync with MATH_STATUSES in #lib/math; the schema folder must not import
// from outside it): not_started, reading, exercises, done, skipped.
export const mathParts = mathSchema.table(
'parts',
{
id: id(),
/** "1".."22" or "appendix". */
key: text('key').notNull(),
/** Part number; null for the appendix. */
num: integer('num'),
title: text('title').notNull(),
sortOrder: integer('sort_order').notNull()
},
(t) => [uniqueIndex('parts_key_key').on(t.key)]
);
export const mathChapters = mathSchema.table(
'chapters',
{
id: id(),
/** Chapter number as text ("0".."94") or the appendix letter ("A".."E"). */
key: text('key').notNull(),
partId: integer('part_id')
.notNull()
.references(() => mathParts.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text('title').notNull(),
/** PDF pages of the free Napkin.pdf (link with #page=N). */
pageStart: integer('page_start').notNull(),
pageEnd: integer('page_end').notNull(),
/** Problems at the end of the chapter; null when the chapter has none. */
exerciseCount: integer('exercise_count'),
/** Chapter keys this one builds on (approximate, from the book's dependency chart). */
prereqs: text('prereqs')
.array()
.notNull()
.default(sql`'{}'::text[]`),
status: text('status').notNull().default('not_started'),
exercisesDone: integer('exercises_done').notNull().default(0),
notes: text('notes'),
startedAt: timestamptz('started_at'),
completedAt: timestamptz('completed_at'),
sortOrder: integer('sort_order').notNull(),
...timestamps
},
(t) => [
uniqueIndex('chapters_key_key').on(t.key),
index('chapters_part_idx').on(t.partId, t.sortOrder),
check(
'chapters_status_check',
sql`${t.status} IN ('not_started','reading','exercises','done','skipped')`
),
check('chapters_exercises_done_check', sql`${t.exercisesDone} >= 0`)
]
);
export const mathSections = mathSchema.table(
'sections',
{
id: id(),
chapterId: integer('chapter_id')
.notNull()
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** Section number, e.g. "3.2" (or "D.1"). */
key: text('key').notNull(),
title: text('title').notNull(),
sortOrder: integer('sort_order').notNull(),
/** Optional per-section tick. */
doneAt: timestamptz('done_at')
},
(t) => [uniqueIndex('sections_chapter_key_key').on(t.chapterId, t.key)]
);
export const mathActivity = mathSchema.table(
'activity',
{
id: id(),
chapterId: integer('chapter_id')
.notNull()
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
sectionId: integer('section_id').references(() => mathSections.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** status | exercises | note | section */
kind: text('kind').notNull(),
fromValue: text('from_value'),
toValue: text('to_value'),
/** exercises: done count change. */
delta: integer('delta'),
/** site | bot | sql (from the math.source setting, see math.apply_progress). */
source: text('source').notNull().default('sql'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('activity_created_idx').on(t.createdAt),
index('activity_chapter_idx').on(t.chapterId, t.createdAt),
check('activity_kind_check', sql`${t.kind} IN ('status','exercises','note','section')`)
]
);

View File

@@ -29,3 +29,4 @@ export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math');

View File

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

View File

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

View File

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

View File

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

View File

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

134
src/lib/server/math.ts Normal file
View File

@@ -0,0 +1,134 @@
// Math tracker (/trackers/math): loads the Napkin outline with progress, and applies progress
// updates through math.apply_progress (the same JSON format bots use, see the migration).
import { client } from '#lib/server/db/index.js';
import { MATH_STATUSES, type MathStatus } from '#lib/math.js';
import * as z from 'zod';
export type MathSection = { id: number; key: string; title: string; done: boolean };
export type MathChapter = {
id: number;
key: string;
partKey: string;
title: string;
pageStart: number;
pageEnd: number;
exerciseCount: number | null;
prereqs: string[];
status: MathStatus;
exercisesDone: number;
notes: string | null;
startedAt: string | null;
completedAt: string | null;
updatedAt: string;
sections: MathSection[];
};
export type MathPart = { key: string; num: number | null; title: string };
export type MathActivity = {
id: number;
chapterKey: string;
chapterTitle: string;
sectionKey: string | null;
kind: 'status' | 'exercises' | 'note' | 'section';
fromValue: string | null;
toValue: string | null;
delta: number | null;
source: string;
at: string;
};
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
export async function loadMath(owner: boolean) {
const [parts, chapters, sections, activity] = await Promise.all([
client`SELECT key, num, title FROM math.parts ORDER BY sort_order`,
client`SELECT c.id, c.key, p.key AS part_key, c.title, c.page_start, c.page_end,
c.exercise_count, c.prereqs, c.status, c.exercises_done, c.notes,
c.started_at, c.completed_at, c.updated_at
FROM math.chapters c JOIN math.parts p ON p.id = c.part_id
ORDER BY c.sort_order`,
client`SELECT id, chapter_id, key, title, done_at FROM math.sections
ORDER BY chapter_id, sort_order`,
client`SELECT a.id, c.key AS chapter_key, c.title AS chapter_title, s.key AS section_key,
a.kind, a.from_value, a.to_value, a.delta, a.source, a.created_at
FROM math.activity a
JOIN math.chapters c ON c.id = a.chapter_id
LEFT JOIN math.sections s ON s.id = a.section_id
ORDER BY a.created_at DESC, a.id DESC LIMIT 25`
]);
const byChapter = new Map<number, MathSection[]>();
for (const s of sections) {
const list = byChapter.get(s.chapter_id) ?? [];
list.push({ id: s.id, key: s.key, title: s.title, done: !!s.done_at });
byChapter.set(s.chapter_id, list);
}
return {
parts: parts.map((p) => ({ key: p.key, num: p.num, title: p.title }) as MathPart),
chapters: chapters.map((c): MathChapter => ({
id: c.id,
key: c.key,
partKey: c.part_key,
title: c.title,
pageStart: c.page_start,
pageEnd: c.page_end,
exerciseCount: c.exercise_count,
prereqs: c.prereqs ?? [],
status: c.status,
exercisesDone: c.exercises_done,
notes: owner ? c.notes : null,
startedAt: iso(c.started_at),
completedAt: iso(c.completed_at),
updatedAt: iso(c.updated_at)!,
sections: byChapter.get(c.id) ?? []
})),
activity: activity
.filter((a) => owner || a.kind !== 'note')
.map((a): MathActivity => ({
id: a.id,
chapterKey: a.chapter_key,
chapterTitle: a.chapter_title,
sectionKey: a.section_key,
kind: a.kind,
fromValue: a.kind === 'note' && !owner ? null : a.from_value,
toValue: a.kind === 'note' && !owner ? null : a.to_value,
delta: a.delta,
source: a.source,
at: iso(a.created_at)!
}))
};
}
const progressInput = z
.object({
chapter: z.string().trim().min(1).max(4),
status: z.enum(MATH_STATUSES).optional(),
exercises_add: z.coerce.number().int().min(-50).max(50).optional(),
exercises_done: z.coerce.number().int().min(0).max(500).optional(),
notes: z.string().max(2000).optional(),
section: z.string().trim().max(12).optional(),
section_done: z.enum(['1', '0']).optional()
})
.strict();
/** One owner update from the page (form fields), applied as a math.apply_progress JSON. */
export async function applyFromForm(form: FormData) {
const raw: Record<string, string> = {};
for (const [k, v] of form) if (typeof v === 'string' && v !== '') raw[k] = v;
if (form.has('notes')) raw.notes = String(form.get('notes') ?? '');
const parsed = progressInput.safeParse(raw);
if (!parsed.success) return { ok: false as const, message: 'Invalid update' };
const p = parsed.data;
const json: Record<string, unknown> = { chapter: p.chapter, source: 'site' };
if (p.status) json.status = p.status;
if (p.exercises_add !== undefined) json.exercises_add = p.exercises_add;
if (p.exercises_done !== undefined) json.exercises_done = p.exercises_done;
if (p.notes !== undefined) json.notes = p.notes;
if (p.section) json[p.section_done === '0' ? 'sections_undone' : 'sections_done'] = [p.section];
try {
await client`SELECT math.apply_progress(${JSON.stringify(json)}::jsonb)`;
return { ok: true as const };
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('unknown chapter')) return { ok: false as const, message: 'Unknown chapter' };
throw e;
}
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
import { MATH_PRIVATE } from '#lib/math.js';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
@@ -37,6 +38,11 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
});
it('gates the math tracker while MATH_PRIVATE is on; writes are always owner-only', () => {
expect(isOwnerOnlyPath('/trackers/math')).toBe(MATH_PRIVATE);
expect(isOwnerOnlyPath('/trackers/math/__data.json')).toBe(MATH_PRIVATE);
expect(isOwnerWritePath('/trackers/math')).toBe(true);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);

View File

@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { MATH_PRIVATE } from '#lib/math.js';
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
@@ -29,15 +30,18 @@ export const ownerOnlyPrefixes = [
'/trackers/books/files',
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
'/trackers/notes',
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
// AI tracker: the news tab (/trackers/ai) is public, bots, usage and setup are mine only.
'/trackers/ai/bots',
'/trackers/ai/usage',
'/trackers/ai/setup',
'/lists/db',
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
'/api/spotify',
// Worldwide todo list: the list is public, the review queue and its API are mine only.
'/projects/worldwide-todo/queue',
'/projects/worldwide-todo/api/queue'
'/projects/worldwide-todo/api/queue',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
/**
@@ -66,7 +70,8 @@ export const ownerWritePrefixes = [
'/trackers/pc',
'/trackers/notes',
'/trackers/ai',
'/trackers/movies'
'/trackers/movies',
'/trackers/math'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,13 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M20 3v4M22 5h-4M4 17v2M5 18H3'
]
},
math: {
from: '#60a5fa',
to: '#1e40af',
ink: WHITE,
// sigma
paths: ['M18 7V4H6l6 8-6 8h12v-3']
},
notes: {
from: '#fde68a',
to: '#f59e0b',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,24 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import { kinds, overview, searchSkins, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
export const load: PageServerLoad = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const kind = url.searchParams.get('kind') ?? '';
const sort = url.searchParams.get('sort') ?? 'popular';
const [stats, dimensions, markets, skins] = await Promise.all([
overview(),
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
streams(),
searchSkins(q, kind, sort)
]);
return {
stats: stats[0] as {
items: number;
observations: string;
earliest: Date | null;
latest: Date | null;
},
stats,
dimensions: dimensions as unknown as { key: string; values: number }[],
markets: markets as unknown as { source: string; currency: string; kind: string }[]
markets,
kinds: kinds.map(([value, label]) => ({ value, label })),
browse: { q, kind, sort, skins }
};
};

View File

@@ -9,6 +9,70 @@
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// Browse: every skin, sticker, case, agent, … with its cheapest Skinport listing today.
const euro = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
type Hit = (typeof data.browse.skins)[number];
// svelte-ignore state_referenced_locally
let browseQuery = $state(data.browse.q);
// svelte-ignore state_referenced_locally
let browseKind = $state(data.browse.kind);
// svelte-ignore state_referenced_locally
let browseSort = $state(data.browse.sort);
// svelte-ignore state_referenced_locally
let hits = $state<Hit[]>(data.browse.skins);
let browsing = $state(false);
let moreLeft = $state(true);
let firstBrowse = true;
async function fetchHits(offset: number, signal?: AbortSignal) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
params.set('offset', String(offset));
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
if (!response.ok) throw new Error(`Search failed (${response.status})`);
return (await response.json()).skins as Hit[];
}
$effect(() => {
void browseQuery;
void browseKind;
void browseSort;
if (firstBrowse) {
firstBrowse = false;
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
browsing = true;
try {
hits = await fetchHits(0, controller.signal);
moreLeft = hits.length >= 60;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
if (browseQuery) params.set('q', browseQuery);
if (browseKind) params.set('kind', browseKind);
if (browseSort !== 'popular') params.set('sort', browseSort);
history.replaceState(history.state, '', params.size ? `?${params}` : location.pathname);
} catch {
/* aborted or failed; keep the last results */
} finally {
if (!controller.signal.aborted) browsing = false;
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
});
async function loadMore() {
browsing = true;
try {
const next = await fetchHits(hits.length);
hits = [...hits, ...next];
moreLeft = next.length >= 60;
} finally {
browsing = false;
}
}
type Result = {
id?: number;
name?: string;
@@ -40,15 +104,12 @@
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('steam-market|USD');
let kind = $state('sale-median');
let sourceChoice = $state('skinport|EUR');
let kind = $state('lowest-listing');
let markets = $derived(
data.markets.length
? data.markets
: [
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
]
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
@@ -90,7 +151,7 @@
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'sale-median';
'lowest-listing';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
@@ -115,8 +176,8 @@
}
const to = new SvelteDate();
const from =
range === 'all' && data.stats.earliest
? new SvelteDate(data.stats.earliest)
range === 'all' && data.stats.earliest_day
? new SvelteDate(data.stats.earliest_day)
: new SvelteDate(to);
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
if (range === '24h') from.setTime(to.getTime() - 86_400_000);
@@ -124,10 +185,12 @@
if (range === '30d') from.setUTCDate(from.getUTCDate() - 30);
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
if (range === 'all' && !data.stats.earliest) from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === 'all' && !data.stats.earliest_day) from.setUTCFullYear(from.getUTCFullYear() - 1);
return { from: from.toISOString(), to: to.toISOString() };
}
function bucketLabel(seconds: number) {
if (seconds >= 7 * 86400) return 'weekly';
if (seconds >= 86400) return 'daily';
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
}
@@ -256,19 +319,73 @@
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
Explore price histories for individual listings or compare groups of items over time.
Every CS2 skin, knife, glove, sticker, case and more, in every wear, StatTrak, Souvenir and
Doppler phase. Prices from Skinport, Steam, CSFloat and Buff163 are archived daily.
</p>
</div>
<div class="stats">
<div><strong>{number.format(data.stats.items)}</strong><span>listings indexed</span></div>
<div><strong>{number.format(data.stats.skins)}</strong><span>items</span></div>
<div><strong>{number.format(data.stats.configs)}</strong><span>configs</span></div>
<div>
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
>stored observations</span
<strong>{number.format(data.stats.latest_rows ?? 0)}</strong><span
>prices {data.stats.latest_day ?? ''}</span
>
</div>
</div>
</header>
<section class="browse" aria-label="Browse items">
<div class="browse-bar">
<Input
type="search"
bind:value={browseQuery}
placeholder="Search AK-47 Redline, Karambit Doppler, Katowice 2014…"
/>
<NativeSelect bind:value={browseSort}
><option value="popular">Most listed</option><option value="price">Most expensive</option
><option value="name">Name</option></NativeSelect
>
</div>
<div class="kinds" role="group" aria-label="Type">
<button class:on={!browseKind} onclick={() => (browseKind = '')}>All</button>
{#each data.kinds as k (k.value)}<button
class:on={browseKind === k.value}
onclick={() => (browseKind = browseKind === k.value ? '' : k.value)}>{k.label}</button
>{/each}
</div>
<div class="hits" class:dim={browsing}>
{#each hits as hit (hit.slug)}
<a
class="hit"
href={`/projects/cs-prices/${hit.slug}`}
style:--rarity={hit.rarity_color ?? 'transparent'}
>
{#if hit.image}<img
src={hit.image}
alt=""
loading="lazy"
width="96"
height="72"
/>{:else}<span class="noimg"></span>{/if}
<span class="hit-name">{hit.name}</span>
<span class="hit-meta"
>{hit.from_cents != null ? `from ${euro.format(hit.from_cents / 100)}` : 'no listings'} ·
{hit.configs}
config{hit.configs === 1 ? '' : 's'}</span
>
</a>
{:else}
<p class="none">{browsing ? 'Searching…' : 'Nothing matches.'}</p>
{/each}
</div>
{#if moreLeft && hits.length >= 60}<div class="more">
<Button variant="outline" size="sm" onclick={loadMore} disabled={browsing}>Show more</Button
>
</div>{/if}
</section>
<h2 class="section-title">Compare over time</h2>
<div class="workspace">
<aside class="picker" aria-label="Series selection">
<div class="panel-heading">
@@ -357,11 +474,10 @@
></label
><label
>Range<NativeSelect bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
value="7d">7 days</option
><option value="30d">30 days</option><option value="1y">1 year</option><option
value="5y">5 years</option
><option value="all">All history</option><option value="custom">Custom</option
><option value="7d">7 days</option><option value="30d">30 days</option><option
value="1y">1 year</option
><option value="5y">5 years</option><option value="all">All history</option><option
value="custom">Custom</option
></NativeSelect
></label
>
@@ -394,9 +510,9 @@
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
Group lines average each listing equally within a bucket; raw observations remain in the
database.
)} · {bucketSeconds ? `${bucketLabel(bucketSeconds)} points` : 'choose a series'}. Group
lines average each config equally per point. Open an item above for its own page with every
config.
</p>
</section>
</div>
@@ -436,6 +552,95 @@
h3 {
font-size: 0.85rem;
}
.browse {
margin-bottom: 2.5rem;
}
.browse-bar {
display: grid;
grid-template-columns: 1fr 170px;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.kinds {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.kinds button {
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-muted);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.74rem;
}
.kinds button:hover {
color: var(--site-text);
border-color: var(--site-text);
}
.kinds button.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.hits {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
transition: opacity 0.15s;
}
.hits.dim {
opacity: 0.55;
}
.hit {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.6rem 0.7rem 0.7rem;
border: 1px solid var(--site-border);
border-bottom: 2px solid var(--rarity);
background: var(--site-surface);
border-radius: 8px;
color: var(--site-text);
text-decoration: none;
transition:
transform 0.15s,
border-color 0.15s;
}
.hit:hover {
transform: translateY(-2px);
border-color: var(--site-text);
border-bottom-color: var(--rarity);
}
.hit img,
.noimg {
width: 100%;
height: 82px;
object-fit: contain;
}
.hit-name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.25;
}
.hit-meta {
color: var(--site-muted);
font-size: 0.68rem;
}
.none {
color: var(--site-muted);
font-size: 0.8rem;
}
.more {
display: flex;
justify-content: center;
margin-top: 0.8rem;
}
.section-title {
font-size: 1.25rem;
margin-bottom: 0.8rem;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
@@ -637,6 +842,15 @@
}
}
@media (max-width: 600px) {
.browse-bar {
grid-template-columns: 1fr 130px;
}
.hits {
grid-template-columns: repeat(2, 1fr);
}
.stats {
gap: 1.2rem;
}
.intro {
display: block;
}

View File

@@ -0,0 +1,23 @@
import { error } from '@sveltejs/kit';
import { loadSeries, loadSkin, pickConfig, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
// One skin (or sticker, case, agent, …) with every market config it has: wear, StatTrak,
// Souvenir and Doppler phase. The selected config comes from ?v=&wear=&phase= so every config
// has its own URL; its full daily history (EUR) is loaded here and the range is picked client-side.
export const load: PageServerLoad = async ({ params, url }) => {
const found = await loadSkin(params.slug);
if (!found) error(404, 'Not Found');
const config = pickConfig(found.configs, url.searchParams);
const [streamList, history] = await Promise.all([
streams(),
config ? loadSeries(config, null) : Promise.resolve(null)
]);
return {
skin: found.skin,
configs: found.configs,
selected: config?.id ?? null,
streams: streamList,
history
};
};

View File

@@ -0,0 +1,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>

View File

@@ -5,12 +5,14 @@ import type { RequestHandler } from './$types';
const maxSeries = 1000;
const maxDays = 365 * 20;
// Compare explorer: daily prices (cs.daily_prices) for up to 1000 configs or groups, bucketed per
// day, or per week for ranges over 400 days. Group lines average each config equally per bucket.
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
const source = typeof body.source === 'string' ? body.source : 'steam-market';
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
const kind = typeof body.kind === 'string' ? body.kind : 'lowest-listing';
const source = typeof body.source === 'string' ? body.source : 'skinport';
const currency = typeof body.currency === 'string' ? body.currency : 'EUR';
const ids = Array.isArray(body.ids) ? body.ids : [];
const values = Array.isArray(body.values) ? body.values : [];
if (
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
)
return Response.json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
return Response.json({ series: [], bucketSeconds: 0 });
return Response.json({ points: [], bucketSeconds: 0 });
const to = new Date(typeof body.to === 'string' ? body.to : Date.now());
const from = new Date(typeof body.from === 'string' ? body.from : to.getTime() - 365 * 86400_000);
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
return Response.json({ error: 'Invalid date range' }, { status: 400 });
// Aim for at most 240 buckets per series. Raw observations remain untouched.
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
const weekly = +to - +from > 400 * 86400_000;
const seconds = weekly ? 7 * 86400 : 86400;
const selection =
dimension === 'item'
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
@@ -44,22 +46,22 @@ export const POST: RequestHandler = async ({ request }) => {
sql`,`
)})`;
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
const bucket = weekly ? sql`date_trunc('week', p.day)::date` : sql`p.day`;
const rows = await db.execute(sql`
WITH per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id,
date_bin(make_interval(secs => ${seconds}), p.observed_at,
TIMESTAMPTZ '1970-01-01') AS bucket,
avg(p.price_cents) AS price_cents,
sum(p.volume) AS volume,
min(p.price_cents) AS min_cents,
max(p.price_cents) AS max_cents
FROM cs.price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz
WITH stream AS (
SELECT id FROM cs.price_streams
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}
), per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id, ${bucket} AS bucket,
avg(p.price_cents) AS price_cents, sum(p.volume) AS volume,
min(p.price_cents) AS min_cents, max(p.price_cents) AS max_cents
FROM cs.daily_prices p ${selection}
WHERE p.stream_id = (SELECT id FROM stream)
AND p.day >= ${from.toISOString().slice(0, 10)}::date
AND p.day <= ${to.toISOString().slice(0, 10)}::date
GROUP BY 1, 2, 3
)
SELECT series_key, bucket, round(avg(price_cents))::int AS price_cents,
SELECT series_key, bucket::text AS bucket, round(avg(price_cents))::int AS price_cents,
count(*)::int AS items, sum(volume)::bigint AS volume,
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
FROM per_item GROUP BY 1, 2 ORDER BY 1, 2`);

View File

@@ -0,0 +1,13 @@
import { searchSkins } from '#lib/server/cs-prices.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const skins = await searchSkins(
url.searchParams.get('q') ?? '',
url.searchParams.get('kind') ?? '',
url.searchParams.get('sort') ?? 'popular',
Number(url.searchParams.get('limit') ?? 60) || 60,
Number(url.searchParams.get('offset') ?? 0) || 0
);
return Response.json({ skins });
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { isOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
// /trackers/ai: the news tab is public; bots, usage and setup are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
export const load: LayoutServerLoad = (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });

View File

@@ -6,6 +6,7 @@
import Newspaper from 'lucide-svelte/icons/newspaper';
import Bot from 'lucide-svelte/icons/bot';
import Gauge from 'lucide-svelte/icons/gauge';
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
let { data, children } = $props();
@@ -14,7 +15,8 @@
...(data.owner
? [
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true },
{ name: 'setup', href: '/trackers/ai/setup', icon: SlidersHorizontal, locked: true }
]
: [])
]);
@@ -25,7 +27,10 @@
<TrackerIcon name="ai" class="size-10" />
<div>
<h1>ai</h1>
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
<p>
AI and software news, plus what my bots are doing, how much I use each model and how each
one is set up.
</p>
</div>
</header>
<nav class="ai-tabs" aria-label="AI tracker sections">
@@ -118,4 +123,7 @@
.tab-usage {
background: linear-gradient(135deg, #34d399, #0d9488);
}
.tab-setup {
background: linear-gradient(135deg, #a78bfa, #6d28d9);
}
</style>

View File

@@ -4,8 +4,9 @@ import type { PageServerLoad } from './$types';
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
export const load: PageServerLoad = async (event) => {
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
// header_already_set (500) for the owner.
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
bots: await loadBots(),
now: Date.now(),

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

View 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>

View File

@@ -6,8 +6,9 @@ import type { Actions, PageServerLoad } from './$types';
// Owner only: dated usage numbers per provider. Actions don't run the load, so each
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
export const load: PageServerLoad = async (event) => {
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
// header_already_set (500) for the owner.
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
entries: await loadUsage(),
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }

View File

@@ -0,0 +1,33 @@
import { MATH_PRIVATE } from '#lib/math.js';
import { applyFromForm, loadMath } from '#lib/server/math.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// Math tracker: Evan Chen's "An Infinitely Large Napkin". Private by default (MATH_PRIVATE in
// #lib/math): then everyone else gets a 404 for the page and its __data.json, and
// hooks.server.ts 404s the path as a backstop. Edits are always owner-only.
export const load: PageServerLoad = async (event) => {
if (MATH_PRIVATE) requireOwner(event);
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return {
owner,
private: MATH_PRIVATE,
now: Date.now(),
...(await loadMath(owner)),
seo: {
title: 'Math tracker',
description: "Working through Evan Chen's An Infinitely Large Napkin, chapter by chapter.",
index: !MATH_PRIVATE
}
};
};
export const actions: Actions = {
progress: async (event) => {
requireOwner(event);
const r = await applyFromForm(await event.request.formData());
return r.ok ? { ok: true } : fail(400, { message: r.message });
}
};

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB