Compare commits
19 Commits
money-dail
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e24ee7f95d | |||
| fda21554cc | |||
| d7e26a3338 | |||
| 44379b9a27 | |||
| f7a3bb556c | |||
| 16684d6688 | |||
| d99245e9fc | |||
| 23a43049a0 | |||
| 3b75fd39b5 | |||
| 76f063b362 | |||
| 7396d23335 | |||
| 22279d181f | |||
| 8d2da984e0 | |||
| 9e89a615c6 | |||
| 9d79dfb43a | |||
| 21b10ea894 | |||
| 78b69c7d8f | |||
| d3b9bb9ba2 | |||
| 0121071af2 |
81
docs/plant-tracker.md
Normal file
81
docs/plant-tracker.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Plant tracker (`/trackers/cargill`)
|
||||
|
||||
A private (owner-only) knowledge base about the Cargill Bioindustrial plant in Gouda:
|
||||
departments, products, process steps, equipment, glossary terms, questions, notes and roles
|
||||
(never colleague names). Logged-out visitors get a 404 on the page, its data and its actions
|
||||
(`ownerOnlyPrefixes` in `src/lib/server/owner.ts`).
|
||||
|
||||
## Schema `plant` (migration `drizzle/20261026133106_plant`)
|
||||
|
||||
- `plant.entries`: `kind`, `slug` (unique per kind), `title`, `summary`, `body` (markdown),
|
||||
`parent_id` (any kind, e.g. a note under a note), `tags`, `sort_order`, `source`,
|
||||
`confidence` (`confirmed` | `assumed` | `open`), `answered_at` (questions), timestamps.
|
||||
- `plant.links`: `from_id`, `to_id`, `relation` (`feeds_into`, `made_in`, `uses`, `part_of`,
|
||||
`related`), optional `label`, `dashed`, `confidence` (default `confirmed`; `assumed` or
|
||||
`open` links show dashed in the flow and with a "?" badge on entry pages).
|
||||
- `plant.pending`: parents and links whose target doesn't exist yet. Every `apply_entries` run
|
||||
retries them, so a later batch that adds the target resolves them. Entry pages list them as
|
||||
"pending".
|
||||
|
||||
Kinds: `department`, `product`, `process_step`, `equipment`, `glossary`, `question`, `note`,
|
||||
`person_role`.
|
||||
|
||||
How tags drive the UI:
|
||||
|
||||
- `cluster` (departments): a section in the departments tab and a card in the overview, with
|
||||
every unit below it (units with `parent: "cluster-ea"`, and their own units, like MP1's).
|
||||
- `inactive`: dimmed everywhere; the "hide inactive" switch hides them (off by default).
|
||||
- `overview` (notes): shown at the top of the overview.
|
||||
|
||||
## Bulk import: `plant.apply_entries(jsonb)`
|
||||
|
||||
Upserts by `(kind, slug)`, then sets parents and links by slug. Run it as the app user or the
|
||||
owner; for big files pipe SQL through stdin, e.g.
|
||||
`select plant.apply_entries($doc$ { ... } $doc$::jsonb);`
|
||||
|
||||
The argument is either an object `{ "source": ..., "entries": [...], "links": [...],
|
||||
"delete": [...] }` or a bare array of entries.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "Walter White, batch 5 (2026-10-26)",
|
||||
"entries": [
|
||||
{
|
||||
"kind": "department",
|
||||
"slug": "414-kolom-1",
|
||||
"title": "414 Kolom 1",
|
||||
"summary": "Fat splitting column, continuous",
|
||||
"body": "In and out:\n- In: feed, water and high-pressure steam.\n- Out: fatty acid and glycerine water.",
|
||||
"parent": "cluster-dim",
|
||||
"tags": ["splitting", "continuous"],
|
||||
"confidence": "confirmed",
|
||||
"links": [{ "to": "splitting", "relation": "uses", "label": "optional" }]
|
||||
},
|
||||
{ "kind": "question", "slug": "rndm", "title": "What is RNDM?", "tags": ["answered"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Required: `kind`, plus `slug` or a `title` to slugify; new entries need a `title`.
|
||||
- Optional: `summary`, `body`, `parent` (alias `parent_slug`; `null` clears it), `tags`,
|
||||
`confidence` (default `assumed`, `open` for questions), `sort_order`, `source`, `answered`,
|
||||
`replace_links`, `replace_tags`, `body_mode` (`"append"`), `links` (`to`, `relation`,
|
||||
`label`, `dashed`, `confidence`, `remove`).
|
||||
- Updating an existing entry:
|
||||
- Keys that are left out keep their current value. An empty `summary` or `body` also keeps
|
||||
the current text. With `"body_mode": "append"` the new body is added below the current
|
||||
one (skipped when it is already there) instead of replacing it.
|
||||
- `tags` are merged into the existing tags, unless the entry sets
|
||||
`"confidence": "confirmed"` or `"replace_tags": true`, which replace them.
|
||||
- The top-level `source` is only the default for new entries; pass `source` on an entry to
|
||||
overwrite it.
|
||||
- A question tagged `answered` (or with `"answered": true`) is marked answered; the tag is
|
||||
dropped.
|
||||
- References (`parent`, `to`, `from`) are a slug or `kind:slug`. A bare slug used by several
|
||||
kinds resolves in this order: process_step, product, department, equipment, note,
|
||||
question, person_role, glossary (so `splitting` is the process step).
|
||||
- Unknown references never abort the import: they are listed in `unresolved` and kept in
|
||||
`plant.pending` until their target exists.
|
||||
|
||||
Returns `{ inserted, updated, linked, unlinked, deleted, unresolved: [{from, field, to}],
|
||||
resolved_pending, pending, entries: [{id, kind, slug, action}] }`.
|
||||
@@ -35,7 +35,9 @@ export default defineConfig({
|
||||
'ai',
|
||||
'movies',
|
||||
'worldtodo',
|
||||
'notes'
|
||||
'notes',
|
||||
'math',
|
||||
'plant'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal file
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
CREATE TYPE "worldtodo"."location_precision" AS ENUM('exact', 'approximate', 'none');--> statement-breakpoint
|
||||
ALTER TABLE "worldtodo"."tasks" ADD COLUMN "location_precision" "worldtodo"."location_precision";--> statement-breakpoint
|
||||
-- Hand-written: location_precision follows scale and lat/lon (map pins, tracker #4641).
|
||||
-- street/city = exact, region/country = approximate (rough centre), planet or no coordinates
|
||||
-- = none. A trigger instead of a generated column so drizzle-kit can diff the enum column.
|
||||
CREATE OR REPLACE FUNCTION worldtodo.set_location_precision() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.location_precision := CASE
|
||||
WHEN NEW.lat IS NULL OR NEW.lon IS NULL OR NEW.scale = 'planet' THEN 'none'
|
||||
WHEN NEW.scale IN ('street', 'city') THEN 'exact'
|
||||
ELSE 'approximate'
|
||||
END::worldtodo.location_precision;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS tasks_location_precision ON worldtodo.tasks;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER tasks_location_precision BEFORE INSERT OR UPDATE OF scale, lat, lon, location_precision
|
||||
ON worldtodo.tasks FOR EACH ROW EXECUTE FUNCTION worldtodo.set_location_precision();
|
||||
--> statement-breakpoint
|
||||
-- Backfill (the trigger computes it) without bumping updated_at: touch_updated_at is paused
|
||||
-- for this one statement when it exists.
|
||||
DO $$
|
||||
DECLARE has_touch boolean := EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgrelid = 'worldtodo.tasks'::regclass AND tgname = 'touch_updated_at'
|
||||
);
|
||||
BEGIN
|
||||
IF has_touch THEN ALTER TABLE worldtodo.tasks DISABLE TRIGGER touch_updated_at; END IF;
|
||||
UPDATE worldtodo.tasks SET location_precision = NULL;
|
||||
IF has_touch THEN ALTER TABLE worldtodo.tasks ENABLE TRIGGER touch_updated_at; END IF;
|
||||
END $$;
|
||||
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal file
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
CREATE TABLE "cs"."daily_prices" (
|
||||
"item_id" integer,
|
||||
"stream_id" smallint,
|
||||
"day" date,
|
||||
"price_cents" integer NOT NULL,
|
||||
"volume" integer,
|
||||
CONSTRAINT "daily_prices_pkey" PRIMARY KEY("item_id","stream_id","day")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."fx_rates" (
|
||||
"day" date PRIMARY KEY,
|
||||
"usd_eur" numeric(12,8) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."price_windows" (
|
||||
"item_id" integer,
|
||||
"source" text,
|
||||
"currency" text NOT NULL,
|
||||
"span" text,
|
||||
"median_cents" integer,
|
||||
"avg_cents" integer,
|
||||
"min_cents" integer,
|
||||
"max_cents" integer,
|
||||
"volume" integer,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "price_windows_pkey" PRIMARY KEY("item_id","source","span")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."skins" (
|
||||
"id" text PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"weapon" text,
|
||||
"pattern" text,
|
||||
"category" text,
|
||||
"rarity" text,
|
||||
"rarity_color" text,
|
||||
"image" text,
|
||||
"min_float" real,
|
||||
"max_float" real,
|
||||
"collections" text[],
|
||||
"source" text DEFAULT 'bymykel' NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "cs"."items_hash_idx";--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "skin_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "phase" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "wear" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "stattrak" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "souvenir" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "id" smallint GENERATED BY DEFAULT AS IDENTITY (sequence name "cs"."price_streams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 32767 START WITH 1 CACHE 1);--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "label" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_fetched_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_rows" integer;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_error" text;--> statement-breakpoint
|
||||
CREATE INDEX "daily_prices_day_idx" ON "cs"."daily_prices" ("day","stream_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "items_config_idx" ON "cs"."items" ("market_hash_name",coalesce("phase", ''));--> statement-breakpoint
|
||||
CREATE INDEX "items_skin_idx" ON "cs"."items" ("skin_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "price_streams_id_idx" ON "cs"."price_streams" ("id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "skins_slug_idx" ON "cs"."skins" ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "skins_name_idx" ON "cs"."skins" ("name");--> statement-breakpoint
|
||||
CREATE INDEX "skins_kind_idx" ON "cs"."skins" ("kind");--> statement-breakpoint
|
||||
ALTER TABLE "cs"."daily_prices" ADD CONSTRAINT "daily_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD CONSTRAINT "items_skin_id_skins_id_fkey" FOREIGN KEY ("skin_id") REFERENCES "cs"."skins"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_windows" ADD CONSTRAINT "price_windows_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
-- Facets for the listings that already exist (the daily job keeps these up to date).
|
||||
UPDATE cs.items SET
|
||||
stattrak = market_hash_name LIKE '%StatTrak™ %',
|
||||
souvenir = market_hash_name LIKE 'Souvenir %',
|
||||
wear = substring(market_hash_name from '\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$');
|
||||
--> statement-breakpoint
|
||||
-- Price streams the daily job writes (one row per config per stream per day).
|
||||
INSERT INTO cs.price_streams (source, currency, kind, label) VALUES
|
||||
('skinport', 'EUR', 'lowest-listing', 'Skinport lowest listing'),
|
||||
('skinport', 'EUR', 'sale-median-24h', 'Skinport sales, 24h median'),
|
||||
('steam-market', 'USD', 'sale-avg', 'Steam Market average sale (24h, else 7d)'),
|
||||
('csfloat', 'USD', 'lowest-listing', 'CSFloat lowest listing'),
|
||||
('buff163', 'USD', 'lowest-listing', 'Buff163 lowest listing')
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label;
|
||||
--> statement-breakpoint
|
||||
UPDATE cs.price_streams SET label = 'Steam Market lowest listing'
|
||||
WHERE source = 'steam-market' AND currency = 'USD' AND kind = 'lowest-listing' AND label IS NULL;
|
||||
--> statement-breakpoint
|
||||
-- Backfill: the one Steam lowest-listing sweep from Sep 2026 becomes the first daily point.
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT DISTINCT ON (p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date)
|
||||
p.item_id, s.id, (p.observed_at AT TIME ZONE 'UTC')::date, p.price_cents, p.volume
|
||||
FROM cs.price_observations p
|
||||
JOIN cs.price_streams s ON s.source = p.source AND s.currency = p.currency AND s.kind = p.kind
|
||||
ORDER BY p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date, p.observed_at DESC
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON cs.skins, cs.daily_prices, cs.price_windows, cs.fx_rates TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cs TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "ai"."provider_setup_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"provider_id" integer NOT NULL,
|
||||
"section" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"note" text,
|
||||
"scope" text,
|
||||
"link" text,
|
||||
"body" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_entries_section_check" CHECK ("section" IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')),
|
||||
CONSTRAINT "provider_setup_entries_scope_check" CHECK ("scope" IS NULL OR "scope" IN ('global','per-repo','app')),
|
||||
CONSTRAINT "provider_setup_entries_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai"."provider_setup_providers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"products" text,
|
||||
"color" text DEFAULT 'zinc' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_providers_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "provider_setup_providers_color_check" CHECK ("color" IN ('orange','green','blue','violet','pink','sky','amber','zinc'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "provider_setup_entries_provider_idx" ON "ai"."provider_setup_entries" ("provider_id","section","sort_order");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "provider_setup_providers_name_key" ON "ai"."provider_setup_providers" ("name");--> statement-breakpoint
|
||||
ALTER TABLE "ai"."provider_setup_entries" ADD CONSTRAINT "provider_setup_entries_JxI1C1Zk458j_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai"."provider_setup_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Seed the provider cards (owner edits them on /trackers/ai/setup). Entries are added there.
|
||||
INSERT INTO "ai"."provider_setup_providers" ("name", "products", "color", "sort_order") VALUES
|
||||
('Claude', 'claude.ai + Claude Code', 'orange', 10),
|
||||
('ChatGPT', 'ChatGPT + Codex', 'green', 20),
|
||||
('Gemini', 'Gemini + Gemini CLI', 'blue', 30),
|
||||
('Grok', 'Grok + Grok Bot', 'violet', 40),
|
||||
('Copilot', 'GitHub Copilot', 'sky', 50)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Same privacy as the rest of schema ai: app role only (default privileges already cover new
|
||||
-- tables; granted explicitly too). Grants only run on prod (migrations as `personal`).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON "ai"."provider_setup_providers", "ai"."provider_setup_entries" TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "cs"."history_fetches" (
|
||||
"item_id" integer,
|
||||
"source" text,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"points" integer DEFAULT 0 NOT NULL,
|
||||
"first_day" date,
|
||||
"error" text,
|
||||
CONSTRAINT "history_fetches_pkey" PRIMARY KEY("item_id","source")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cs"."history_fetches" ADD CONSTRAINT "history_fetches_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;
|
||||
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
983
drizzle/20261026104832_math/migration.sql
Normal file
983
drizzle/20261026104832_math/migration.sql
Normal 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 $$;
|
||||
43310
drizzle/20261026104832_math/snapshot.json
Normal file
43310
drizzle/20261026104832_math/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1141
drizzle/20261026133106_plant/migration.sql
Normal file
1141
drizzle/20261026133106_plant/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
||||
"wildlife:refresh": "scripts/wildlife-refresh.sh",
|
||||
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
||||
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
|
||||
"cs-prices:daily": "bun scripts/cs-prices.ts daily",
|
||||
"house:worker": "bun scripts/house-worker.ts",
|
||||
"house": "bun scripts/house-listing.ts",
|
||||
"house:add": "bun scripts/house-listing.ts add",
|
||||
|
||||
399
scripts/cs-history.ts
Normal file
399
scripts/cs-history.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* CS2 price history backfill into cs.daily_prices, under separate `*-history` streams so it never
|
||||
* mixes with the daily scrape. Resumable (cs.history_fetches) and throttled; run in the background:
|
||||
*
|
||||
* bun scripts/cs-history.ts fx ECB USD→EUR for every day since 2013 (Frankfurter),
|
||||
* weekends/holidays carry the last rate forward
|
||||
* bun scripts/cs-history.ts steam [max] Steam Community Market daily median sale price +
|
||||
* volume since listing, read from the public market
|
||||
* listing page (no login). One page holds every wear /
|
||||
* StatTrak / Souvenir sibling, so it is ~1 request per
|
||||
* skin, paced ~9 s apart with backoff on 429.
|
||||
* bun scripts/cs-history.ts csfloat-queue print CSFloat targets (NDJSON) for an off-box fetcher
|
||||
* bun scripts/cs-history.ts csfloat-import read CSFloat graph NDJSON on stdin and store it
|
||||
* bun scripts/cs-history.ts status coverage per history stream
|
||||
*
|
||||
* Steam prices arrive in the page's wallet currency (EUR here, `ecurrency` 3) and are stored as
|
||||
* is; USD streams (CSFloat) are converted per day with cs.fx_rates on read.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
|
||||
const mode = process.argv[2] ?? 'status';
|
||||
const ua = 'Mozilla/5.0';
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const log = (...args: unknown[]) => console.log(new Date().toISOString().slice(0, 19), ...args);
|
||||
|
||||
const streams = {
|
||||
steam: {
|
||||
source: 'steam-market',
|
||||
kind: 'sale-median-history',
|
||||
label: 'Steam Market daily median sale (history)'
|
||||
},
|
||||
csfloat: {
|
||||
source: 'csfloat',
|
||||
kind: 'sale-avg-history',
|
||||
label: 'CSFloat daily average sale (history)'
|
||||
}
|
||||
};
|
||||
async function streamId(source: string, currency: string, kind: string, label: string) {
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.price_streams (source, currency, kind, label)
|
||||
VALUES (${source}, ${currency}, ${kind}, ${label})
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label RETURNING id`;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
type Day = { day: string; cents: number; volume: number | null };
|
||||
async function store(stream: number, item: number, days: Day[]) {
|
||||
for (let offset = 0; offset < days.length; offset += 5000) {
|
||||
const chunk = days.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT ${item}::int, ${stream}::smallint, day, price_cents, volume
|
||||
FROM unnest(${chunk.map((d) => d.day)}::date[], ${chunk.map((d) => d.cents)}::int[],
|
||||
${chunk.map((d) => d.volume)}::int[]) AS x(day, price_cents, volume)
|
||||
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||
}
|
||||
}
|
||||
async function mark(item: number, source: string, days: Day[], error: string | null = null) {
|
||||
await sql`
|
||||
INSERT INTO cs.history_fetches (item_id, source, fetched_at, points, first_day, error)
|
||||
VALUES (${item}, ${source}, now(), ${days.length}, ${days[0]?.day ?? null}, ${error})
|
||||
ON CONFLICT (item_id, source) DO UPDATE SET fetched_at = now(), points = EXCLUDED.points,
|
||||
first_day = EXCLUDED.first_day, error = EXCLUDED.error`;
|
||||
}
|
||||
|
||||
async function fx() {
|
||||
const from = '2013-01-01';
|
||||
const to = new Date().toISOString().slice(0, 10);
|
||||
let rates: Record<string, { EUR: number }> | undefined;
|
||||
for (const url of [
|
||||
`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=EUR`,
|
||||
`https://api.frankfurter.app/${from}..${to}?from=USD&to=EUR`
|
||||
]) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (response.ok) {
|
||||
rates = ((await response.json()) as { rates: typeof rates }).rates;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rates) throw new Error('No FX history');
|
||||
const days: string[] = [];
|
||||
const values: number[] = [];
|
||||
let last: number | null = null;
|
||||
for (let t = Date.parse(from); t <= Date.parse(to); t += 86_400_000) {
|
||||
const day = new Date(t).toISOString().slice(0, 10);
|
||||
last = rates[day]?.EUR ?? last;
|
||||
if (last != null) {
|
||||
days.push(day);
|
||||
values.push(last);
|
||||
}
|
||||
}
|
||||
// Existing rows (the daily scrape's own rate) win.
|
||||
const result = await sql`
|
||||
INSERT INTO cs.fx_rates (day, usd_eur)
|
||||
SELECT * FROM unnest(${days}::date[], ${values}::numeric[]) ON CONFLICT (day) DO NOTHING`;
|
||||
log(
|
||||
`FX: ${Object.keys(rates).length} ECB fixings, ${result.count} days inserted (${days[0]}..${to})`
|
||||
);
|
||||
}
|
||||
|
||||
/** Every pricehistory query embedded in the listing page's SSR state. */
|
||||
function pageHistories(html: string) {
|
||||
const match = html.match(
|
||||
/<script type="application\/json" id="valve-ssr-data"[^>]*>([\s\S]*?)<\/script>/
|
||||
);
|
||||
if (!match) return null;
|
||||
const found = new Map<
|
||||
string,
|
||||
{ currency: number; prices: { time: number; price_median: number; purchases: number }[] }
|
||||
>();
|
||||
const walk = (node: unknown): void => {
|
||||
if (typeof node === 'string') {
|
||||
if (node.length > 2 && (node[0] === '{' || node[0] === '[')) {
|
||||
try {
|
||||
walk(JSON.parse(node));
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>;
|
||||
const key = record.queryKey;
|
||||
if (
|
||||
Array.isArray(key) &&
|
||||
key[0] === 'market' &&
|
||||
key[1] === 'pricehistory' &&
|
||||
key[2] === 730
|
||||
) {
|
||||
const data = (record.state as { data?: { ecurrency?: number; prices?: [] } } | undefined)
|
||||
?.data;
|
||||
if (data && Array.isArray(data.prices))
|
||||
found.set(String(key[3]), { currency: data.ecurrency ?? 0, prices: data.prices });
|
||||
}
|
||||
for (const value of Object.values(record)) walk(value);
|
||||
}
|
||||
};
|
||||
walk(JSON.parse(match[1]));
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Hourly points (last month) collapse to one row per UTC day, volume-weighted. */
|
||||
function daily(prices: { time: number; price_median: number; purchases: number }[]): Day[] {
|
||||
const byDay = new Map<string, { sum: number; weight: number; volume: number }>();
|
||||
for (const p of prices) {
|
||||
if (!Number.isFinite(p.price_median) || p.price_median <= 0) continue;
|
||||
const day = new Date(p.time * 1000).toISOString().slice(0, 10);
|
||||
const weight = Math.max(1, p.purchases || 0);
|
||||
const entry = byDay.get(day) ?? { sum: 0, weight: 0, volume: 0 };
|
||||
entry.sum += p.price_median * weight;
|
||||
entry.weight += weight;
|
||||
entry.volume += p.purchases || 0;
|
||||
byDay.set(day, entry);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, e]) => ({ day, cents: Math.round((e.sum / e.weight) * 100), volume: e.volume }))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000);
|
||||
}
|
||||
|
||||
async function curlPage(url: string) {
|
||||
const proc = Bun.spawn(
|
||||
['curl', '-sL', '--compressed', '--max-time', '60', '-A', ua, '-w', '\n%{http_code}', url],
|
||||
{ stdout: 'pipe', stderr: 'ignore' }
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const cut = out.lastIndexOf('\n');
|
||||
return { status: Number(out.slice(cut + 1)) || 0, body: out.slice(0, cut) };
|
||||
}
|
||||
|
||||
const kindOrder = [
|
||||
'knife',
|
||||
'gloves',
|
||||
'weapon',
|
||||
'container',
|
||||
'agent',
|
||||
'key',
|
||||
'music-kit',
|
||||
'patch',
|
||||
'collectible',
|
||||
'sticker',
|
||||
'graffiti',
|
||||
'charm',
|
||||
'other'
|
||||
];
|
||||
|
||||
async function steam(max: number) {
|
||||
const currencies: Record<number, string> = { 1: 'USD', 3: 'EUR' };
|
||||
const ids: Record<string, number> = {};
|
||||
const idFor = async (currency: string) =>
|
||||
(ids[currency] ??= await streamId(
|
||||
streams.steam.source,
|
||||
currency,
|
||||
streams.steam.kind,
|
||||
streams.steam.label
|
||||
));
|
||||
await idFor('EUR');
|
||||
// One representative unfetched phase-less config per skin, popular kinds/skins first.
|
||||
const queue = await sql<{ skin_id: string; item_id: number; hash: string }[]>`
|
||||
WITH todo AS (
|
||||
SELECT i.id, i.skin_id, i.market_hash_name, s.kind,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w
|
||||
WHERE w.item_id = i.id AND w.source = 'skinport' AND w.span = '90d'), 0) AS vol
|
||||
FROM cs.items i JOIN cs.skins s ON s.id = i.skin_id
|
||||
WHERE i.phase IS NULL AND NOT EXISTS (SELECT 1 FROM cs.history_fetches f
|
||||
WHERE f.item_id = i.id AND f.source = 'steam')
|
||||
), skins AS (
|
||||
SELECT skin_id, kind, sum(vol) AS vol FROM todo GROUP BY 1, 2
|
||||
)
|
||||
SELECT DISTINCT ON (s.vol, s.skin_id) s.skin_id, t.id AS item_id, t.market_hash_name AS hash
|
||||
FROM skins s JOIN todo t ON t.skin_id = s.skin_id
|
||||
ORDER BY s.vol DESC, s.skin_id, t.vol DESC, t.id`;
|
||||
const kinds = new Map(
|
||||
(await sql<{ id: string; kind: string }[]>`SELECT id, kind FROM cs.skins`).map((r) => [
|
||||
r.id,
|
||||
r.kind
|
||||
])
|
||||
);
|
||||
const rank = (skin: string) => {
|
||||
const index = kindOrder.indexOf(kinds.get(skin) ?? 'other');
|
||||
return index < 0 ? kindOrder.length : index;
|
||||
};
|
||||
const ordered = queue
|
||||
.map((row, index) => ({ ...row, index }))
|
||||
.sort((a, b) => rank(a.skin_id) - rank(b.skin_id) || a.index - b.index);
|
||||
log(`Steam: ${ordered.length} skins with unfetched configs`);
|
||||
let delay = 9000;
|
||||
let strikes = 0;
|
||||
let requests = 0;
|
||||
let stored = 0;
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const row = ordered[i];
|
||||
if (requests >= max) break;
|
||||
// Skip when an earlier page already covered this skin's representative.
|
||||
const [done] =
|
||||
await sql`SELECT 1 FROM cs.history_fetches WHERE item_id = ${row.item_id} AND source = 'steam'`;
|
||||
if (done) continue;
|
||||
const siblings = await sql<{ id: number; hash: string }[]>`
|
||||
SELECT id, market_hash_name AS hash FROM cs.items WHERE skin_id = ${row.skin_id} AND phase IS NULL`;
|
||||
const byHash = new Map(siblings.map((s) => [s.hash, s.id]));
|
||||
const url = `https://steamcommunity.com/market/listings/730/${encodeURIComponent(row.hash)}`;
|
||||
requests++;
|
||||
// curl with a bare UA and no Accept-Language: Steam answers Bun's fetch, and any request
|
||||
// with Accept-Language, with 429 but serves this normally.
|
||||
let page: { status: number; body: string };
|
||||
try {
|
||||
page = await curlPage(url);
|
||||
} catch (cause) {
|
||||
log(`net error ${row.hash}: ${String(cause)}`);
|
||||
await sleep(60_000);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
if (page.status === 429 || page.status >= 500 || page.status === 0) {
|
||||
strikes++;
|
||||
delay = Math.min(30_000, delay + 1000);
|
||||
const pause = Math.min(30 * 60_000, 60_000 * strikes);
|
||||
log(
|
||||
`HTTP ${page.status} (strike ${strikes}), pausing ${pause / 1000}s, delay now ${delay / 1000}s`
|
||||
);
|
||||
await sleep(pause);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
strikes = 0;
|
||||
if (page.status !== 200) {
|
||||
await mark(row.item_id, 'steam', [], `HTTP ${page.status}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
const histories = pageHistories(page.body);
|
||||
if (!histories) {
|
||||
await mark(row.item_id, 'steam', [], 'no ssr data');
|
||||
log(`no SSR data for ${row.hash}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
let pageRows = 0;
|
||||
for (const [hash, history] of histories) {
|
||||
const item =
|
||||
byHash.get(hash) ??
|
||||
(
|
||||
await sql<{ id: number }[]>`SELECT id FROM cs.items WHERE market_hash_name = ${hash}
|
||||
AND phase IS NULL`
|
||||
)[0]?.id;
|
||||
if (!item) continue;
|
||||
const currency = currencies[history.currency];
|
||||
if (!currency) {
|
||||
await mark(item, 'steam', [], `currency ${history.currency}`);
|
||||
continue;
|
||||
}
|
||||
const days = daily(history.prices);
|
||||
if (days.length) await store(await idFor(currency), item, days);
|
||||
await mark(item, 'steam', days);
|
||||
pageRows += days.length;
|
||||
}
|
||||
if (!histories.has(row.hash)) await mark(row.item_id, 'steam', [], 'not on page');
|
||||
stored += pageRows;
|
||||
if (requests % 25 === 0 || requests <= 3)
|
||||
log(
|
||||
`#${requests} ${row.hash}: ${histories.size} configs, ${pageRows} days (total ${stored})`
|
||||
);
|
||||
await sleep(delay + Math.random() * 3000);
|
||||
}
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${stored}
|
||||
WHERE source = ${streams.steam.source} AND kind = ${streams.steam.kind}`;
|
||||
log(`Steam done: ${requests} requests, ${stored} day rows`);
|
||||
}
|
||||
|
||||
/** Targets for the CSFloat fetcher: phase-less configs plus Doppler phases (paint index added by
|
||||
* the fetcher), most traded first, skipping ones already fetched. */
|
||||
async function csfloatQueue() {
|
||||
const rows = await sql<{ item_id: number; hash: string; phase: string | null; vol: number }[]>`
|
||||
SELECT i.id AS item_id, i.market_hash_name AS hash, i.phase,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w WHERE w.item_id = i.id
|
||||
AND w.span = '90d'), 0)::int AS vol
|
||||
FROM cs.items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cs.history_fetches f WHERE f.item_id = i.id AND f.source = 'csfloat')
|
||||
ORDER BY vol DESC, i.id`;
|
||||
for (const row of rows) process.stdout.write(JSON.stringify(row) + '\n');
|
||||
}
|
||||
|
||||
async function csfloatImport() {
|
||||
const stream = await streamId(
|
||||
streams.csfloat.source,
|
||||
'USD',
|
||||
streams.csfloat.kind,
|
||||
streams.csfloat.label
|
||||
);
|
||||
let items = 0;
|
||||
let rows = 0;
|
||||
let buffer = '';
|
||||
const handle = async (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
const entry = JSON.parse(line) as {
|
||||
item_id: number;
|
||||
error?: string;
|
||||
graph?: { day: string; avg_price: number; count: number }[];
|
||||
};
|
||||
const days = (entry.graph ?? [])
|
||||
.map((g) => ({
|
||||
day: g.day.slice(0, 10),
|
||||
cents: Math.round(g.avg_price),
|
||||
volume: g.count ?? null
|
||||
}))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000)
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
if (days.length) await store(stream, entry.item_id, days);
|
||||
await mark(entry.item_id, 'csfloat', days, entry.error ?? null);
|
||||
items++;
|
||||
rows += days.length;
|
||||
};
|
||||
for await (const chunk of Bun.stdin.stream()) {
|
||||
buffer += new TextDecoder().decode(chunk);
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) await handle(line);
|
||||
}
|
||||
await handle(buffer);
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${rows} WHERE id = ${stream}`;
|
||||
log(`CSFloat import: ${items} configs, ${rows} day rows`);
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const rows = await sql`
|
||||
SELECT st.source, st.kind, st.currency, count(*)::int AS rows, count(DISTINCT dp.item_id)::int AS configs,
|
||||
min(dp.day)::text AS first_day, max(dp.day)::text AS last_day
|
||||
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||
WHERE st.kind LIKE '%-history' GROUP BY 1, 2, 3 ORDER BY 1`;
|
||||
console.table(rows);
|
||||
const [cov] = await sql`
|
||||
WITH h AS (SELECT dp.item_id, count(DISTINCT dp.day) AS days FROM cs.daily_prices dp
|
||||
JOIN cs.price_streams st ON st.id = dp.stream_id WHERE st.kind LIKE '%-history' GROUP BY 1)
|
||||
SELECT (SELECT count(*) FROM cs.items)::int AS configs, count(*)::int AS with_history,
|
||||
count(*) FILTER (WHERE days > 30)::int AS over_30_days FROM h`;
|
||||
console.log(cov);
|
||||
console.table(
|
||||
await sql`SELECT source, count(*)::int AS fetched, sum(points)::int AS points,
|
||||
count(*) FILTER (WHERE points > 0)::int AS with_points, max(fetched_at) AS last
|
||||
FROM cs.history_fetches GROUP BY 1`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'fx') await fx();
|
||||
else if (mode === 'steam') await steam(Number(process.argv[3] ?? Infinity));
|
||||
else if (mode === 'csfloat-queue') await csfloatQueue();
|
||||
else if (mode === 'csfloat-import') await csfloatImport();
|
||||
else if (mode === 'status') await status();
|
||||
else throw new Error('Usage: cs-history.ts fx|steam [max]|csfloat-queue|csfloat-import|status');
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
624
scripts/cs-prices.ts
Normal file
624
scripts/cs-prices.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* CS2 catalog + daily prices (all configs) into cs.*. Runs inside the app container:
|
||||
*
|
||||
* bun scripts/cs-prices.ts daily catalog if older than 6 days, then today's prices
|
||||
* bun scripts/cs-prices.ts catalog refresh the full catalog from ByMykel/CSGO-API
|
||||
* bun scripts/cs-prices.ts prices today's prices from every bulk source
|
||||
*
|
||||
* One row per config per stream per UTC day (cs.daily_prices, idempotent upsert), so a rerun on
|
||||
* the same day just refreshes that day. Bulk endpoints only, no per-item Steam scraping:
|
||||
* - Skinport /v1/items (EUR, lowest listing + quantity, Doppler phases via `version`)
|
||||
* - Skinport /v1/sales/history (EUR, 24h/7d/30d/90d sale aggregates)
|
||||
* - prices.csgotrader.app (USD): Steam Market average sales (24h, else 7d), CSFloat and
|
||||
* Buff163 lowest listings (with Doppler phases)
|
||||
* USD→EUR comes from the ECB rate (Frankfurter) and is stored per day in cs.fx_rates.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
import { dimensionsFromName } from '../src/lib/cs-prices/taxonomy';
|
||||
import { parseConfig, slugify } from '../src/lib/cs-prices/catalog';
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
|
||||
const mode = process.argv[2] ?? 'daily';
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const ua = { 'User-Agent': 'stanrunge.dev cs-prices/2.0 (daily personal price archive)' };
|
||||
const bymykel = 'https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en';
|
||||
|
||||
type Skin = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
weapon: string | null;
|
||||
pattern: string | null;
|
||||
category: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
image: string | null;
|
||||
min_float: number | null;
|
||||
max_float: number | null;
|
||||
collections: string[] | null;
|
||||
source: string;
|
||||
};
|
||||
type Item = {
|
||||
market_hash_name: string;
|
||||
phase: string | null;
|
||||
skin_id: string;
|
||||
name: string;
|
||||
icon_url: string | null;
|
||||
item_type: string | null;
|
||||
wear: string | null;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
};
|
||||
|
||||
async function getJson<T>(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
tries = 4
|
||||
): Promise<T> {
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < tries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { ...ua, ...headers },
|
||||
signal: AbortSignal.timeout(180_000)
|
||||
});
|
||||
if (response.ok) return (await response.json()) as T;
|
||||
last = new Error(`HTTP ${response.status} for ${new URL(url).host}${new URL(url).pathname}`);
|
||||
if (response.status !== 429 && response.status < 500) break;
|
||||
} catch (cause) {
|
||||
last = cause;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000 * 2 ** attempt));
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
const configKey = (hash: string, phase: string | null) => `${hash}\u0000${phase ?? ''}`;
|
||||
const cents = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 21_000_000
|
||||
? Math.round(value * 100)
|
||||
: null;
|
||||
|
||||
function kindFor(file: string, category?: string): string {
|
||||
if (file === 'skins_not_grouped') {
|
||||
if (category === 'Knives') return 'knife';
|
||||
if (category === 'Gloves') return 'gloves';
|
||||
return 'weapon';
|
||||
}
|
||||
return (
|
||||
{
|
||||
stickers: 'sticker',
|
||||
crates: 'container',
|
||||
agents: 'agent',
|
||||
keychains: 'charm',
|
||||
collectibles: 'collectible',
|
||||
graffiti: 'graffiti',
|
||||
patches: 'patch',
|
||||
music_kits: 'music-kit',
|
||||
keys: 'key'
|
||||
}[file] ?? 'other'
|
||||
);
|
||||
}
|
||||
|
||||
/** Feed-only names (e.g. sticker slabs, highlight charms) still get a skin page. */
|
||||
function kindFromName(hash: string) {
|
||||
const { base, starred } = parseConfig(hash);
|
||||
const head = base.split(' | ')[0];
|
||||
if (starred) return /Gloves|Wraps/.test(head) ? 'gloves' : 'knife';
|
||||
if (/^Sticker Slab$|^Charm$|Highlight/.test(head) || /^Souvenir Charm/.test(hash)) return 'charm';
|
||||
if (/^Sticker$/.test(head)) return 'sticker';
|
||||
if (/^(Sealed )?Graffiti$/.test(head)) return 'graffiti';
|
||||
if (/^Patch$/.test(head)) return 'patch';
|
||||
if (/^Music Kit$/.test(head)) return 'music-kit';
|
||||
if (/Case|Capsule|Package|Container|Collection/.test(base)) return 'container';
|
||||
if (dimensionsFromName(hash).category === 'weapon') return 'weapon';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
async function upsertSkins(skins: Skin[]) {
|
||||
for (let offset = 0; offset < skins.length; offset += 2000) {
|
||||
const chunk = skins.slice(offset, offset + 2000);
|
||||
await sql`
|
||||
INSERT INTO cs.skins (id, slug, name, kind, weapon, pattern, category, rarity, rarity_color,
|
||||
image, min_float, max_float, collections, source, updated_at)
|
||||
SELECT id, slug, name, kind, weapon, pattern, category, rarity, rarity_color, image,
|
||||
min_float, max_float, collections, source, now()
|
||||
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(id text, slug text, name text,
|
||||
kind text, weapon text, pattern text, category text, rarity text, rarity_color text,
|
||||
image text, min_float real, max_float real, collections text[], source text)
|
||||
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, kind = EXCLUDED.kind,
|
||||
weapon = coalesce(EXCLUDED.weapon, cs.skins.weapon),
|
||||
pattern = coalesce(EXCLUDED.pattern, cs.skins.pattern),
|
||||
category = coalesce(EXCLUDED.category, cs.skins.category),
|
||||
rarity = coalesce(EXCLUDED.rarity, cs.skins.rarity),
|
||||
rarity_color = coalesce(EXCLUDED.rarity_color, cs.skins.rarity_color),
|
||||
image = coalesce(EXCLUDED.image, cs.skins.image),
|
||||
min_float = coalesce(EXCLUDED.min_float, cs.skins.min_float),
|
||||
max_float = coalesce(EXCLUDED.max_float, cs.skins.max_float),
|
||||
collections = coalesce(EXCLUDED.collections, cs.skins.collections),
|
||||
source = CASE WHEN EXCLUDED.source = 'bymykel' THEN 'bymykel' ELSE cs.skins.source END,
|
||||
updated_at = CASE WHEN EXCLUDED.source = 'bymykel' THEN now() ELSE cs.skins.updated_at END`;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertItems(items: Item[]) {
|
||||
const ids = new Map<string, number>();
|
||||
for (let offset = 0; offset < items.length; offset += 2000) {
|
||||
const chunk = items.slice(offset, offset + 2000);
|
||||
const rows = await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
||||
INSERT INTO cs.items (market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
||||
stattrak, souvenir)
|
||||
SELECT market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
||||
coalesce(stattrak, false), coalesce(souvenir, false)
|
||||
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(market_hash_name text,
|
||||
phase text, skin_id text, name text, icon_url text, item_type text, wear text,
|
||||
stattrak boolean, souvenir boolean)
|
||||
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
|
||||
skin_id = EXCLUDED.skin_id,
|
||||
name = EXCLUDED.name,
|
||||
icon_url = coalesce(EXCLUDED.icon_url, cs.items.icon_url),
|
||||
item_type = coalesce(EXCLUDED.item_type, cs.items.item_type),
|
||||
wear = EXCLUDED.wear, stattrak = EXCLUDED.stattrak, souvenir = EXCLUDED.souvenir,
|
||||
last_seen_at = now()
|
||||
RETURNING id, market_hash_name, phase`;
|
||||
for (const row of rows) ids.set(configKey(row.market_hash_name, row.phase), row.id);
|
||||
// Facets for the /projects/cs-prices compare explorer.
|
||||
const dims: { item_id: number; key: string; value: string }[] = [];
|
||||
const byKey = new Map(
|
||||
chunk.map((item) => [configKey(item.market_hash_name, item.phase), item])
|
||||
);
|
||||
for (const row of rows) {
|
||||
const item = byKey.get(configKey(row.market_hash_name, row.phase));
|
||||
const facets = dimensionsFromName(row.market_hash_name);
|
||||
if (row.phase) facets.phase = row.phase;
|
||||
if (item?.item_type) facets.steam_type = item.item_type;
|
||||
for (const [key, value] of Object.entries(facets))
|
||||
if (value) dims.push({ item_id: row.id, key, value });
|
||||
}
|
||||
if (dims.length)
|
||||
await sql`
|
||||
INSERT INTO cs.item_dimensions (item_id, key, value)
|
||||
SELECT item_id, key, value FROM jsonb_to_recordset(${sql.json(dims as never)}::jsonb)
|
||||
AS x(item_id int, key text, value text)
|
||||
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
type Raw = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
market_hash_name?: string | null;
|
||||
image?: string | null;
|
||||
phase?: string | null;
|
||||
skin_id?: string;
|
||||
weapon?: { name?: string } | null;
|
||||
pattern?: { name?: string } | null;
|
||||
category?: { name?: string } | null;
|
||||
rarity?: { name?: string; color?: string } | null;
|
||||
min_float?: number | null;
|
||||
max_float?: number | null;
|
||||
collections?: { name?: string }[] | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
async function catalog() {
|
||||
const files = [
|
||||
'skins_not_grouped',
|
||||
'stickers',
|
||||
'crates',
|
||||
'agents',
|
||||
'keychains',
|
||||
'collectibles',
|
||||
'graffiti',
|
||||
'patches',
|
||||
'music_kits',
|
||||
'keys'
|
||||
];
|
||||
const skins = new Map<string, Skin>();
|
||||
const items = new Map<string, Item>();
|
||||
const slugOwner = new Map<string, string>();
|
||||
const existing = await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`;
|
||||
for (const row of existing) slugOwner.set(row.slug, row.id);
|
||||
const skinFor = (base: string) => {
|
||||
// The grouping key is the base name; the id stays stable once assigned.
|
||||
const slug = slugify(base);
|
||||
return slugOwner.get(slug) ?? slug;
|
||||
};
|
||||
for (const file of files) {
|
||||
const rows = await getJson<Raw[]>(`${bymykel}/${file}.json`);
|
||||
console.log(`ByMykel ${file}: ${rows.length}`);
|
||||
for (const raw of rows) {
|
||||
if (!raw.market_hash_name) continue;
|
||||
const config = parseConfig(raw.market_hash_name);
|
||||
const kind = kindFor(file, raw.category?.name);
|
||||
const id = skinFor(config.base);
|
||||
slugOwner.set(slugify(config.base), id);
|
||||
const current = skins.get(id);
|
||||
const preferImage = !raw.phase || raw.phase === 'Phase 2';
|
||||
if (!current)
|
||||
skins.set(id, {
|
||||
id,
|
||||
slug: slugify(config.base),
|
||||
name: config.display,
|
||||
kind,
|
||||
weapon: raw.weapon?.name ?? null,
|
||||
pattern: raw.pattern?.name ?? null,
|
||||
category: raw.category?.name ?? raw.type ?? null,
|
||||
rarity: raw.rarity?.name ?? null,
|
||||
rarity_color: raw.rarity?.color ?? null,
|
||||
image: preferImage ? (raw.image ?? null) : null,
|
||||
min_float: raw.min_float ?? null,
|
||||
max_float: raw.max_float ?? null,
|
||||
collections: raw.collections?.map((c) => c.name ?? '').filter(Boolean) ?? null,
|
||||
source: 'bymykel'
|
||||
});
|
||||
else if (!current.image && preferImage && raw.image) current.image = raw.image;
|
||||
const add = (phase: string | null) =>
|
||||
items.set(configKey(raw.market_hash_name!, phase), {
|
||||
market_hash_name: raw.market_hash_name!,
|
||||
phase,
|
||||
skin_id: id,
|
||||
name: phase ? `${raw.market_hash_name} - ${phase}` : raw.market_hash_name!,
|
||||
icon_url: raw.image ?? null,
|
||||
item_type: raw.category?.name ?? raw.type ?? kind,
|
||||
wear: config.wear,
|
||||
stattrak: config.stattrak,
|
||||
souvenir: config.souvenir
|
||||
});
|
||||
add(raw.phase ?? null);
|
||||
// The phase-less listing carries Steam's and other phase-blind prices.
|
||||
if (raw.phase && !items.has(configKey(raw.market_hash_name, null))) add(null);
|
||||
}
|
||||
}
|
||||
const itemList = [...items.values()];
|
||||
for (const skin of skins.values())
|
||||
if (!skin.image)
|
||||
skin.image =
|
||||
itemList.find((item) => item.skin_id === skin.id && item.icon_url)?.icon_url ?? null;
|
||||
// Phase-less rows of Doppler listings should not show one phase's image.
|
||||
for (const item of items.values())
|
||||
if (!item.phase && /Doppler/.test(item.name)) item.icon_url = null;
|
||||
await upsertSkins([...skins.values()]);
|
||||
const ids = await upsertItems([...items.values()]);
|
||||
// Older Steam-only rows: link them to a skin by name.
|
||||
await sql`UPDATE cs.items i SET skin_id = s.id FROM cs.skins s
|
||||
WHERE i.skin_id IS NULL AND s.slug = regexp_replace(regexp_replace(lower(regexp_replace(
|
||||
regexp_replace(regexp_replace(i.market_hash_name, '^★ ', ''), '^(StatTrak™ |Souvenir )', ''),
|
||||
' \\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\\)$', '')),
|
||||
'[^a-z0-9]+', '-', 'g'), '^-+|-+$', '', 'g')`;
|
||||
console.log(`Catalog: ${skins.size} skins, ${ids.size} configs`);
|
||||
}
|
||||
|
||||
type Row = { hash: string; phase: string | null; cents: number; volume: number | null };
|
||||
|
||||
async function ensureItems(rows: Row[]) {
|
||||
const known = new Map<string, number>();
|
||||
for (const row of await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
||||
SELECT id, market_hash_name, phase FROM cs.items`)
|
||||
known.set(configKey(row.market_hash_name, row.phase), row.id);
|
||||
const missing = new Map<string, Item>();
|
||||
const newSkins = new Map<string, Skin>();
|
||||
const slugs = new Map(
|
||||
(await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`).map((r) => [
|
||||
r.slug,
|
||||
r.id
|
||||
])
|
||||
);
|
||||
for (const row of rows) {
|
||||
const key = configKey(row.hash, row.phase);
|
||||
if (known.has(key) || missing.has(key)) continue;
|
||||
// Untranslated tokens and truncated names in the feeds are not real listings.
|
||||
if (/^#|\|\s*$|^\s*$/.test(row.hash) || row.hash.length > 200) continue;
|
||||
const config = parseConfig(row.hash);
|
||||
const slug = slugify(config.base);
|
||||
let skinId = slugs.get(slug);
|
||||
if (!skinId) {
|
||||
skinId = slug;
|
||||
slugs.set(slug, skinId);
|
||||
newSkins.set(skinId, {
|
||||
id: skinId,
|
||||
slug,
|
||||
name: config.display,
|
||||
kind: kindFromName(row.hash),
|
||||
weapon: null,
|
||||
pattern: null,
|
||||
category: null,
|
||||
rarity: null,
|
||||
rarity_color: null,
|
||||
image: null,
|
||||
min_float: null,
|
||||
max_float: null,
|
||||
collections: null,
|
||||
source: 'market'
|
||||
});
|
||||
}
|
||||
missing.set(key, {
|
||||
market_hash_name: row.hash,
|
||||
phase: row.phase,
|
||||
skin_id: skinId,
|
||||
name: row.phase ? `${row.hash} - ${row.phase}` : row.hash,
|
||||
icon_url: null,
|
||||
item_type: null,
|
||||
wear: config.wear,
|
||||
stattrak: config.stattrak,
|
||||
souvenir: config.souvenir
|
||||
});
|
||||
}
|
||||
if (newSkins.size) await upsertSkins([...newSkins.values()]);
|
||||
if (missing.size) {
|
||||
for (const [key, id] of await upsertItems([...missing.values()])) known.set(key, id);
|
||||
console.log(`New configs from price feeds: ${missing.size} (${newSkins.size} new skins)`);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
async function streamId(source: string, currency: string, kind: string) {
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.price_streams (source, currency, kind) VALUES (${source}, ${currency}, ${kind})
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET source = EXCLUDED.source RETURNING id`;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function saveDaily(source: string, currency: string, kind: string, rows: Row[]) {
|
||||
const stream = await streamId(source, currency, kind);
|
||||
const ids = await ensureItems(rows);
|
||||
const dedup = new Map<number, Row>();
|
||||
for (const row of rows) {
|
||||
const id = ids.get(configKey(row.hash, row.phase));
|
||||
if (id) dedup.set(id, row);
|
||||
}
|
||||
const entries = [...dedup.entries()];
|
||||
for (let offset = 0; offset < entries.length; offset += 5000) {
|
||||
const chunk = entries.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT item_id, ${stream}::smallint, ${day}::date, price_cents, volume
|
||||
FROM unnest(${chunk.map(([id]) => id)}::int[], ${chunk.map(([, r]) => r.cents)}::int[],
|
||||
${chunk.map(([, r]) => r.volume)}::int[]) AS x(item_id, price_cents, volume)
|
||||
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||
}
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${entries.length},
|
||||
last_error = NULL WHERE id = ${stream}`;
|
||||
console.log(`${source} ${kind}: ${entries.length} configs for ${day}`);
|
||||
return ids;
|
||||
}
|
||||
|
||||
type Window = {
|
||||
item: number;
|
||||
span: string;
|
||||
median: number | null;
|
||||
avg: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
volume: number | null;
|
||||
};
|
||||
async function saveWindows(source: string, currency: string, windows: Window[]) {
|
||||
for (let offset = 0; offset < windows.length; offset += 5000) {
|
||||
const chunk = windows.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.price_windows (item_id, source, currency, span, median_cents, avg_cents,
|
||||
min_cents, max_cents, volume, updated_at)
|
||||
SELECT item_id, ${source}, ${currency}, span, median_cents, avg_cents, min_cents, max_cents,
|
||||
volume, now()
|
||||
FROM unnest(${chunk.map((w) => w.item)}::int[], ${chunk.map((w) => w.span)}::text[],
|
||||
${chunk.map((w) => w.median)}::int[], ${chunk.map((w) => w.avg)}::int[],
|
||||
${chunk.map((w) => w.min)}::int[], ${chunk.map((w) => w.max)}::int[],
|
||||
${chunk.map((w) => w.volume)}::int[])
|
||||
AS x(item_id, span, median_cents, avg_cents, min_cents, max_cents, volume)
|
||||
ON CONFLICT (item_id, source, span) DO UPDATE SET median_cents = EXCLUDED.median_cents,
|
||||
avg_cents = EXCLUDED.avg_cents, min_cents = EXCLUDED.min_cents,
|
||||
max_cents = EXCLUDED.max_cents, volume = EXCLUDED.volume, updated_at = now()`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fx() {
|
||||
for (const url of [
|
||||
'https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR',
|
||||
'https://api.frankfurter.app/latest?from=USD&to=EUR'
|
||||
]) {
|
||||
try {
|
||||
const data = await getJson<{ rates?: { EUR?: number } }>(url, {}, 2);
|
||||
if (data.rates?.EUR) {
|
||||
await sql`INSERT INTO cs.fx_rates (day, usd_eur) VALUES (${day}, ${data.rates.EUR})
|
||||
ON CONFLICT (day) DO UPDATE SET usd_eur = EXCLUDED.usd_eur`;
|
||||
console.log(`FX USD→EUR ${data.rates.EUR}`);
|
||||
return;
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn(`FX ${new URL(url).host}: ${String(cause)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function guarded(
|
||||
source: string,
|
||||
currency: string,
|
||||
kinds: string[],
|
||||
run: () => Promise<void>
|
||||
) {
|
||||
try {
|
||||
await run();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
console.error(`${source} failed: ${String(cause)}`);
|
||||
for (const kind of kinds)
|
||||
await sql`UPDATE cs.price_streams SET last_error = ${String(cause).slice(0, 500)}
|
||||
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spans = { last_24_hours: '24h', last_7_days: '7d', last_30_days: '30d', last_90_days: '90d' };
|
||||
|
||||
async function prices() {
|
||||
await fx();
|
||||
const results: boolean[] = [];
|
||||
const br = { 'Accept-Encoding': 'br' };
|
||||
results.push(
|
||||
await guarded('skinport', 'EUR', ['lowest-listing'], async () => {
|
||||
const items = await getJson<
|
||||
{
|
||||
market_hash_name: string;
|
||||
version: string | null;
|
||||
min_price: number | null;
|
||||
quantity: number;
|
||||
}[]
|
||||
>('https://api.skinport.com/v1/items?app_id=730¤cy=EUR', br);
|
||||
await saveDaily(
|
||||
'skinport',
|
||||
'EUR',
|
||||
'lowest-listing',
|
||||
items.flatMap((item) => {
|
||||
const value = cents(item.min_price);
|
||||
return value == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
hash: item.market_hash_name,
|
||||
phase: item.version || null,
|
||||
cents: value,
|
||||
volume: item.quantity ?? null
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
results.push(
|
||||
await guarded('skinport', 'EUR', ['sale-median-24h'], async () => {
|
||||
type Agg = {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
median: number | null;
|
||||
volume: number;
|
||||
};
|
||||
const history = await getJson<
|
||||
(Record<string, Agg> & { market_hash_name: string; version: string | null })[]
|
||||
>('https://api.skinport.com/v1/sales/history?app_id=730¤cy=EUR', br);
|
||||
const rows: Row[] = [];
|
||||
for (const entry of history) {
|
||||
const day24 = entry.last_24_hours;
|
||||
const value = cents(day24?.median);
|
||||
if (value != null)
|
||||
rows.push({
|
||||
hash: entry.market_hash_name,
|
||||
phase: entry.version || null,
|
||||
cents: value,
|
||||
volume: day24.volume
|
||||
});
|
||||
}
|
||||
const ids = await saveDaily('skinport', 'EUR', 'sale-median-24h', rows);
|
||||
const windows: Window[] = [];
|
||||
const allIds = await ensureItems(
|
||||
history.map((e) => ({
|
||||
hash: e.market_hash_name,
|
||||
phase: e.version || null,
|
||||
cents: 0,
|
||||
volume: 0
|
||||
}))
|
||||
);
|
||||
for (const entry of history) {
|
||||
const item = (ids.get(configKey(entry.market_hash_name, entry.version || null)) ??
|
||||
allIds.get(configKey(entry.market_hash_name, entry.version || null)))!;
|
||||
if (!item) continue;
|
||||
for (const [field, span] of Object.entries(spans)) {
|
||||
const agg = entry[field];
|
||||
if (!agg || !agg.volume) continue;
|
||||
windows.push({
|
||||
item,
|
||||
span,
|
||||
median: cents(agg.median),
|
||||
avg: cents(agg.avg),
|
||||
min: cents(agg.min),
|
||||
max: cents(agg.max),
|
||||
volume: agg.volume
|
||||
});
|
||||
}
|
||||
}
|
||||
await saveWindows('skinport', 'EUR', windows);
|
||||
console.log(`skinport windows: ${windows.length}`);
|
||||
})
|
||||
);
|
||||
results.push(
|
||||
await guarded('steam-market', 'USD', ['sale-avg'], async () => {
|
||||
const steam = await getJson<Record<string, Record<string, number | null>>>(
|
||||
'https://prices.csgotrader.app/latest/steam.json'
|
||||
);
|
||||
const rows: Row[] = [];
|
||||
for (const [hash, value] of Object.entries(steam)) {
|
||||
// Latest average sale price: the last 24 hours, else the last 7 days.
|
||||
const price = cents(value?.last_24h ?? value?.last_7d);
|
||||
if (price != null) rows.push({ hash, phase: null, cents: price, volume: null });
|
||||
}
|
||||
const ids = await saveDaily('steam-market', 'USD', 'sale-avg', rows);
|
||||
const all = await ensureItems(
|
||||
Object.keys(steam).map((hash) => ({ hash, phase: null, cents: 0, volume: 0 }))
|
||||
);
|
||||
const windows: Window[] = [];
|
||||
for (const [hash, value] of Object.entries(steam)) {
|
||||
const item = ids.get(configKey(hash, null)) ?? all.get(configKey(hash, null));
|
||||
if (!item || !value) continue;
|
||||
for (const span of ['24h', '7d', '30d', '90d']) {
|
||||
const avg = cents(value[`last_${span}`]);
|
||||
if (avg != null)
|
||||
windows.push({ item, span, median: null, avg, min: null, max: null, volume: null });
|
||||
}
|
||||
}
|
||||
await saveWindows('steam-market', 'USD', windows);
|
||||
})
|
||||
);
|
||||
for (const [source, file] of [
|
||||
['csfloat', 'csfloat'],
|
||||
['buff163', 'buff163']
|
||||
] as const) {
|
||||
results.push(
|
||||
await guarded(source, 'USD', ['lowest-listing'], async () => {
|
||||
type Entry = {
|
||||
price?: number;
|
||||
starting_at?: { price?: number | null; doppler?: Record<string, number> } | null;
|
||||
doppler?: Record<string, number>;
|
||||
};
|
||||
const feed = await getJson<Record<string, Entry>>(
|
||||
`https://prices.csgotrader.app/latest/${file}.json`
|
||||
);
|
||||
const rows: Row[] = [];
|
||||
for (const [hash, entry] of Object.entries(feed)) {
|
||||
const base = cents(entry?.starting_at?.price ?? entry?.price);
|
||||
if (base != null) rows.push({ hash, phase: null, cents: base, volume: null });
|
||||
const phases = entry?.starting_at?.doppler ?? entry?.doppler;
|
||||
for (const [phase, value] of Object.entries(phases ?? {})) {
|
||||
const price = cents(value);
|
||||
if (price != null) rows.push({ hash, phase, cents: price, volume: null });
|
||||
}
|
||||
}
|
||||
await saveDaily(source, 'USD', 'lowest-listing', rows);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!results.some(Boolean)) throw new Error('Every price source failed');
|
||||
const [summary] = await sql<{ rows: number; items: number }[]>`
|
||||
SELECT count(*)::int AS rows, count(DISTINCT item_id)::int AS items
|
||||
FROM cs.daily_prices WHERE day = ${day}`;
|
||||
console.log(`Today (${day}): ${summary.rows} price rows across ${summary.items} configs`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'catalog') await catalog();
|
||||
else if (mode === 'prices') await prices();
|
||||
else if (mode === 'daily') {
|
||||
const [state] = await sql<{ fresh: boolean }[]>`
|
||||
SELECT coalesce(max(updated_at) > now() - interval '6 days', false) AS fresh
|
||||
FROM cs.skins WHERE source = 'bymykel'`;
|
||||
if (!state.fresh) await catalog();
|
||||
await prices();
|
||||
} else throw new Error('Usage: cs-prices.ts daily|catalog|prices');
|
||||
await sql`ANALYZE cs.items`;
|
||||
await sql`ANALYZE cs.daily_prices`;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -188,8 +188,9 @@ try {
|
||||
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
|
||||
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
|
||||
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
|
||||
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
|
||||
const AI_TABLES = 3;
|
||||
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
|
||||
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
|
||||
const AI_TABLES = 5;
|
||||
const [ai] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
@@ -286,6 +287,61 @@ 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`);
|
||||
// Cargill plant tracker (owner-only): app role read/write, personal_ro none, and the app can run
|
||||
// plant.apply_entries (the bulk import). 2 = entries, links; the migration seeds the factory guide.
|
||||
const PLANT_TABLES = 3;
|
||||
const [plant] = 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', 'plant', 'usage') as public_usage,
|
||||
has_function_privilege('plant.apply_entries(jsonb)', 'execute') as can_apply
|
||||
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'plant' and c.relkind = 'r'
|
||||
`;
|
||||
if (
|
||||
Number(plant.tables) !== PLANT_TABLES ||
|
||||
Number(plant.app_rw) !== PLANT_TABLES ||
|
||||
Number(plant.ro_read) !== 0 ||
|
||||
plant.public_usage ||
|
||||
!plant.can_apply
|
||||
)
|
||||
throw new Error(`plant schema tables/grants are wrong: ${JSON.stringify(plant)}`);
|
||||
const [{ n: plantEntries }] = await client`select count(*)::int as n from plant.entries`;
|
||||
if (!plantEntries) throw new Error('plant.entries is empty, expected the seeded factory guide');
|
||||
// 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 +351,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), plant: ${plant.tables} tables (${plantEntries} entries), 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Steam Community Market ingestion. Run after db:migrate:cs-prices.
|
||||
* Legacy Steam Community Market ingestion (per item, rate limited). The daily archive is
|
||||
* scripts/cs-prices.ts; this stays for one-off Steam history imports with a logged-in session.
|
||||
*
|
||||
* bun scripts/ingest-cs-prices.ts catalog [--limit=500] [--start=0]
|
||||
* STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... bun scripts/ingest-cs-prices.ts history [--limit=100] [--start=0]
|
||||
@@ -87,10 +88,10 @@ async function itemId(
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
|
||||
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
|
||||
ON CONFLICT (market_hash_name) DO UPDATE SET
|
||||
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
|
||||
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
|
||||
icon_url = COALESCE(EXCLUDED.icon_url, cs.items.icon_url),
|
||||
item_type = COALESCE(EXCLUDED.item_type, cs.items.item_type),
|
||||
last_seen_at = now()
|
||||
RETURNING id`;
|
||||
const inferred = dimensionsFromName(name);
|
||||
@@ -281,7 +282,7 @@ try {
|
||||
else if (mode === 'history') await history();
|
||||
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
|
||||
else throw new Error('Usage: ingest-cs-prices.ts catalog|history|import <file>');
|
||||
await sql`ANALYZE cs_market_items, cs_item_dimensions, cs_price_observations`;
|
||||
await sql`ANALYZE cs.items, cs.item_dimensions, cs.price_observations`;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
// category 'transfer' (e.g. Personal <-> Joint legs)
|
||||
// --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the
|
||||
// existing tracker rows those statement rows matched
|
||||
// Interest/dividend payouts (src/lib/server/investment-income.ts) get category 'interest' /
|
||||
// 'dividend' on insert; matched existing rows get it only when their category is empty.
|
||||
// --apply write (needs a money-schema backup in ~/backups from the last 2 hours)
|
||||
// --drop-start-balance with --apply: delete the account's "Start balance" row(s), only if the
|
||||
// statement opens at €0 and, after the import, the only difference
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
planImport,
|
||||
rowsFromTable,
|
||||
selectRows,
|
||||
statementCategory,
|
||||
statementExternalId,
|
||||
type StatementRow,
|
||||
type TrackerRow
|
||||
@@ -293,12 +296,23 @@ try {
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, value_date, description, amount_cents, external_id, source, booking_status, category, raw)
|
||||
values (${account.user_id}, ${account.id}, ${r.date}, ${r.startedDate}, ${r.description.slice(0, 500)},
|
||||
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : null},
|
||||
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : statementCategory(r)},
|
||||
${q.json({ revolut: { type: r.type, product: r.product, started: r.startedDate, completed: r.completedAt, description: r.description, fee_cents: r.feeCents, currency: r.currency, balance_cents: r.balanceCents, file_line: r.line } })})
|
||||
on conflict do nothing`;
|
||||
inserted += res.count;
|
||||
}
|
||||
console.log(`Inserted ${inserted} rows into ${account.name}.`);
|
||||
// Interest/dividend payouts that matched an existing row: tag it, unless it already
|
||||
// has a category (hand-set categories are never overwritten).
|
||||
let income = 0;
|
||||
for (const d of plan.duplicates) {
|
||||
const category = isTransfer(d.row) ? null : statementCategory(d.row);
|
||||
if (!category) continue;
|
||||
const res = await q`update money.transactions set category = ${category}
|
||||
where id = ${d.match.id} and account_id = ${account.id} and category is null`;
|
||||
income += res.count;
|
||||
}
|
||||
if (income) console.log(`Tagged ${income} existing interest/dividend rows.`);
|
||||
if (transferRe && flag('--tag-matched-transfers')) {
|
||||
const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id);
|
||||
const tagged = ids.length
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { ECharts } from 'echarts/core';
|
||||
|
||||
export type PriceSeries = { key: string; name: string; points: [number, number][] };
|
||||
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
|
||||
$props();
|
||||
export type PriceSeries = {
|
||||
key: string;
|
||||
name: string;
|
||||
points: [number, number][];
|
||||
color?: string;
|
||||
dashed?: boolean;
|
||||
};
|
||||
let {
|
||||
series,
|
||||
kind,
|
||||
currency,
|
||||
height = 'min(62vh, 560px)',
|
||||
zoom = true
|
||||
}: {
|
||||
series: PriceSeries[];
|
||||
kind: string;
|
||||
currency: string;
|
||||
height?: string;
|
||||
zoom?: boolean;
|
||||
} = $props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
|
||||
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
@@ -22,15 +39,53 @@
|
||||
const foreground = style.getPropertyValue('--site-text').trim();
|
||||
const muted = style.getPropertyValue('--site-muted').trim();
|
||||
const border = style.getPropertyValue('--site-border').trim();
|
||||
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
|
||||
const many = series.length > 100;
|
||||
const legend = series.length > 1 && series.length <= 12;
|
||||
const surface = style.getPropertyValue('--site-surface').trim();
|
||||
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
|
||||
let first = Infinity;
|
||||
let last = -Infinity;
|
||||
for (const line of series)
|
||||
for (const [t] of line.points) {
|
||||
if (t < first) first = t;
|
||||
if (t > last) last = t;
|
||||
}
|
||||
const day = 86_400_000;
|
||||
const short = Number.isFinite(first) && last - first < 7 * day;
|
||||
const pad = short ? (7 * day - (last - first)) / 2 : 0;
|
||||
chart.setOption(
|
||||
{
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 78, right: 20, top: 28, bottom: 72 },
|
||||
grid: {
|
||||
left: 64,
|
||||
right: 14,
|
||||
top: legend ? 40 : 20,
|
||||
bottom: zoom ? 64 : 28,
|
||||
containLabel: false
|
||||
},
|
||||
legend: legend
|
||||
? {
|
||||
top: 0,
|
||||
left: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 4,
|
||||
textStyle: { color: muted, fontSize: 11 }
|
||||
}
|
||||
: { show: false },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: short ? first - pad : undefined,
|
||||
max: short ? last + pad : undefined,
|
||||
minInterval: day,
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted },
|
||||
axisLabel: {
|
||||
color: muted,
|
||||
hideOverlap: true,
|
||||
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
|
||||
},
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
@@ -39,44 +94,71 @@
|
||||
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
|
||||
splitLine: { lineStyle: { color: border } }
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 12,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
],
|
||||
tooltip:
|
||||
series.length > 100
|
||||
? { show: false }
|
||||
dataZoom: zoom
|
||||
? [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
tooltip: many
|
||||
? { show: false }
|
||||
: legend || series.length === 1
|
||||
? {
|
||||
trigger: 'axis',
|
||||
backgroundColor: surface,
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground, fontSize: 12 },
|
||||
formatter: (
|
||||
params: {
|
||||
seriesName: string;
|
||||
value: [number, number];
|
||||
color: string;
|
||||
}[]
|
||||
) =>
|
||||
params.length
|
||||
? `${new Date(params[0].value[0]).toLocaleDateString('nl-NL')}<br>` +
|
||||
params
|
||||
.map(
|
||||
(p) =>
|
||||
`<span style="color:${p.color}">●</span> ${escapeHtml(p.seriesName)} · <b>${money.format(p.value[1] / 100)}</b>`
|
||||
)
|
||||
.join('<br>')
|
||||
: ''
|
||||
}
|
||||
: {
|
||||
trigger: 'item',
|
||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
||||
backgroundColor: surface,
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground },
|
||||
formatter: (params: { seriesName: string; value: [number, number] }) =>
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString('nl-NL')} · ${money.format(params.value[1] / 100)}`
|
||||
},
|
||||
series: series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.points,
|
||||
showSymbol: false,
|
||||
// Few points (a new archive, or one sweep) would be invisible as a bare line.
|
||||
showSymbol: item.points.length <= 60,
|
||||
symbolSize: item.points.length <= 3 ? 7 : 4,
|
||||
connectNulls: false,
|
||||
silent: series.length > 100,
|
||||
silent: many,
|
||||
lineStyle: {
|
||||
width: series.length > 100 ? 0.7 : 1.8,
|
||||
opacity: series.length > 100 ? 0.42 : 0.9
|
||||
width: many ? 0.7 : 1.8,
|
||||
opacity: many ? 0.42 : 0.9,
|
||||
type: item.dashed ? 'dashed' : 'solid'
|
||||
},
|
||||
itemStyle: {
|
||||
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
|
||||
color: item.color ?? `hsl(${(index * 137.508) % 360} 65% ${dark ? '68%' : '38%'})`
|
||||
},
|
||||
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
|
||||
large: true
|
||||
large: many
|
||||
}))
|
||||
},
|
||||
true
|
||||
@@ -90,7 +172,7 @@
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
@@ -98,7 +180,14 @@
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
|
||||
echarts.use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
LegendComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||
ready = true;
|
||||
update();
|
||||
@@ -107,7 +196,7 @@
|
||||
theme = new MutationObserver(update);
|
||||
theme.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
attributeFilter: ['data-theme', 'class']
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -118,12 +207,16 @@
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
// Track the props so a new selection redraws.
|
||||
void series;
|
||||
void currency;
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="chart"
|
||||
style:height
|
||||
bind:this={element}
|
||||
role="img"
|
||||
aria-label={`${series.length} ${kind} price series chart`}
|
||||
@@ -132,7 +225,6 @@
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: min(62vh, 560px);
|
||||
min-height: 320px;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/lib/cs-prices/catalog.test.ts
Normal file
43
src/lib/cs-prices/catalog.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { configRank, parseConfig, slugify } from './catalog';
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('splits knives, StatTrak and wear', () => {
|
||||
expect(parseConfig('★ StatTrak™ Karambit | Doppler (Factory New)')).toEqual({
|
||||
starred: true,
|
||||
stattrak: true,
|
||||
souvenir: false,
|
||||
wear: 'Factory New',
|
||||
base: 'Karambit | Doppler',
|
||||
display: '★ Karambit | Doppler'
|
||||
});
|
||||
});
|
||||
it('handles souvenirs and wearless items', () => {
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').souvenir).toBe(true);
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').base).toBe('AWP | Dragon Lore');
|
||||
expect(parseConfig('Sticker | Heroic (Holo) | Rio 2022').base).toBe(
|
||||
'Sticker | Heroic (Holo) | Rio 2022'
|
||||
);
|
||||
expect(parseConfig('Paris 2023 Mirage Souvenir Package').souvenir).toBe(false);
|
||||
expect(parseConfig('StatTrak™ Music Kit | Daniel Sadowski, Crimson Assault').base).toBe(
|
||||
'Music Kit | Daniel Sadowski, Crimson Assault'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('makes stable slugs', () => {
|
||||
expect(slugify('★ Karambit | Doppler')).toBe('karambit-doppler');
|
||||
expect(slugify('AK-47 | Redline')).toBe('ak-47-redline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configRank', () => {
|
||||
it('sorts normal, then StatTrak, by wear and phase', () => {
|
||||
const a = configRank({ stattrak: false, souvenir: false, wear: 'Factory New', phase: 'Ruby' });
|
||||
const b = configRank({ stattrak: false, souvenir: false, wear: 'Minimal Wear', phase: null });
|
||||
const c = configRank({ stattrak: true, souvenir: false, wear: 'Factory New', phase: null });
|
||||
expect(a).toBeLessThan(b);
|
||||
expect(b).toBeLessThan(c);
|
||||
});
|
||||
});
|
||||
87
src/lib/cs-prices/catalog.ts
Normal file
87
src/lib/cs-prices/catalog.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure helpers shared by scripts/cs-prices.ts and the item pages: how a market hash name maps to
|
||||
// its skin (the page that groups every wear, StatTrak, Souvenir and phase config).
|
||||
|
||||
export const wearOrder = [
|
||||
'Factory New',
|
||||
'Minimal Wear',
|
||||
'Field-Tested',
|
||||
'Well-Worn',
|
||||
'Battle-Scarred'
|
||||
] as const;
|
||||
export const wearShort: Record<string, string> = {
|
||||
'Factory New': 'FN',
|
||||
'Minimal Wear': 'MW',
|
||||
'Field-Tested': 'FT',
|
||||
'Well-Worn': 'WW',
|
||||
'Battle-Scarred': 'BS'
|
||||
};
|
||||
export const phaseOrder = [
|
||||
'Phase 1',
|
||||
'Phase 2',
|
||||
'Phase 3',
|
||||
'Phase 4',
|
||||
'Ruby',
|
||||
'Sapphire',
|
||||
'Black Pearl',
|
||||
'Emerald'
|
||||
] as const;
|
||||
|
||||
const wearSuffix = / \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$/;
|
||||
|
||||
export type Config = {
|
||||
starred: boolean;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
/** Name without ★, StatTrak™, Souvenir or wear: the skin grouping key. */
|
||||
base: string;
|
||||
/** Display name of the skin: base with ★ for knives and gloves. */
|
||||
display: string;
|
||||
};
|
||||
|
||||
export function parseConfig(marketHashName: string): Config {
|
||||
let name = marketHashName.trim();
|
||||
const starred = name.startsWith('★ ');
|
||||
if (starred) name = name.slice(2);
|
||||
let stattrak = false;
|
||||
let souvenir = false;
|
||||
if (name.startsWith('StatTrak™ ')) {
|
||||
stattrak = true;
|
||||
name = name.slice('StatTrak™ '.length);
|
||||
}
|
||||
if (name.startsWith('Souvenir ') && name.includes(' | ')) {
|
||||
souvenir = true;
|
||||
name = name.slice('Souvenir '.length);
|
||||
}
|
||||
const wear = wearSuffix.exec(name)?.[1] ?? null;
|
||||
if (wear) name = name.replace(wearSuffix, '');
|
||||
return { starred, stattrak, souvenir, wear, base: name, display: starred ? `★ ${name}` : name };
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return (
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/★/g, '')
|
||||
.replace(/™/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 120) || 'item'
|
||||
);
|
||||
}
|
||||
|
||||
/** Order configs: normal before StatTrak before Souvenir, then wear, then phase. */
|
||||
export function configRank(row: {
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
phase: string | null;
|
||||
}) {
|
||||
const variant = row.souvenir ? 2 : row.stattrak ? 1 : 0;
|
||||
const wear = row.wear ? wearOrder.indexOf(row.wear as (typeof wearOrder)[number]) + 1 : 0;
|
||||
const phase = row.phase ? phaseOrder.indexOf(row.phase as (typeof phaseOrder)[number]) + 1 : 0;
|
||||
return variant * 1000 + wear * 20 + phase;
|
||||
}
|
||||
39
src/lib/math.ts
Normal file
39
src/lib/math.ts
Normal 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 = false;
|
||||
|
||||
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(', ');
|
||||
}
|
||||
@@ -105,14 +105,17 @@
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
|
||||
/** Actual part through `projectedFrom` and projected part from the last actual point onward. */
|
||||
function segments(item: ChartSeries) {
|
||||
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
|
||||
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
|
||||
if (!item.projectedFrom) return { actual: item.points, projected: [] };
|
||||
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
|
||||
return split < 0
|
||||
? { solid: [], dashed: item.points }
|
||||
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
|
||||
? { actual: [], projected: item.points }
|
||||
: { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
|
||||
}
|
||||
/** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
|
||||
const lineWidth = (item: ChartSeries, projected: boolean) =>
|
||||
projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
|
||||
function pointAt(points: ChartPoint[], date: string) {
|
||||
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
|
||||
return points.findLast((point) => point.date <= date) ?? null;
|
||||
@@ -236,16 +239,17 @@
|
||||
{/if}
|
||||
{#each series as item (item.label)}
|
||||
{@const parts = segments(item)}
|
||||
{#each [parts.solid, parts.dashed] as points, index (index)}
|
||||
{#each [parts.actual, parts.projected] as points, index (index)}
|
||||
{#if points.length > 1}
|
||||
<path
|
||||
d={path(points)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-width={item.emphasis ? 3 : 1.75}
|
||||
stroke-dasharray={index ? '6 5' : undefined}
|
||||
stroke-linecap={index ? 'butt' : 'round'}
|
||||
stroke-width={lineWidth(item, index === 1)}
|
||||
stroke-opacity={index ? 0.8 : 1}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
data-line={index ? 'projected' : 'actual'}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -306,16 +310,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
|
||||
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
|
||||
<figcaption class="legend">
|
||||
{#each series as item (item.label)}
|
||||
<span class:emphasis={item.emphasis}
|
||||
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
||||
>
|
||||
{/each}
|
||||
{#if series.some((item) => item.dashedFrom)}
|
||||
<span class="line-key first"><span class="key solid"></span>Actual</span>
|
||||
<span class="line-key"><span class="key dashed"></span>Projected</span>
|
||||
{#if series.some((item) => item.projectedFrom)}
|
||||
<span class="line-key first"><span class="key actual"></span>Actual</span>
|
||||
<span class="line-key"><span class="key projected"></span>Projected</span>
|
||||
{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
@@ -419,10 +423,12 @@
|
||||
.key {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
border-top: 2px solid var(--site-muted);
|
||||
border-top: 3px solid var(--site-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.key.dashed {
|
||||
border-top-style: dashed;
|
||||
.key.projected {
|
||||
border-top-width: 1.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
|
||||
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
|
||||
{ key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
|
||||
{ key: 'calmYears', label: 'Calm start (no crashes)', unit: 'yr' },
|
||||
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
|
||||
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
|
||||
]
|
||||
@@ -61,6 +62,7 @@
|
||||
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
|
||||
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
|
||||
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
|
||||
{ key: 'salaryCapGrowthPercent', label: 'Plateau growth (real)', unit: '%/yr' },
|
||||
{ key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
|
||||
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
|
||||
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
|
||||
@@ -101,7 +103,21 @@
|
||||
: 'Not by the end';
|
||||
let active = $derived(params[selected]);
|
||||
let result = $derived(results[selected]);
|
||||
let normalReturn = $derived(marketReturns(active).normal);
|
||||
let markets = $derived(marketReturns(active, result.years[0]?.year));
|
||||
let normalReturn = $derived(markets.normal);
|
||||
/** First crash after any calm start. */
|
||||
let firstCrash = $derived.by(() => {
|
||||
let year = active.firstCrashYear;
|
||||
const every = Math.max(1, Math.round(active.crashEveryYears));
|
||||
while (markets.calmUntil !== null && year <= markets.calmUntil) year += every;
|
||||
return year;
|
||||
});
|
||||
/** Crash and correction years in the first ten years, for the explanation. */
|
||||
let shocks = $derived(
|
||||
Array.from({ length: 11 }, (_, offset) => (result.years[0]?.year ?? 0) + offset)
|
||||
.map((year) => ({ year, ...markets.yearReturn(year) }))
|
||||
.filter((row) => row.kind === 'crash' || row.kind === 'correction')
|
||||
);
|
||||
/** Salary path rows through the retirement year. */
|
||||
let career = $derived(
|
||||
result.years.filter(
|
||||
@@ -163,11 +179,24 @@
|
||||
<p class="muted scenario-portfolio">
|
||||
<strong>{active.label} portfolio:</strong>
|
||||
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
|
||||
{#if markets.calmUntil !== null}No crash or down year through {markets.calmUntil}: those years
|
||||
earn the {active.returnPercent}% average.
|
||||
{/if}
|
||||
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
|
||||
from {active.firstCrashYear}{active.correctionPercent !== 0
|
||||
from {firstCrash}{active.correctionPercent !== 0
|
||||
? `, a ${active.correctionPercent}% correction halfway between`
|
||||
: ''}, and {percent(normalReturn)} in the other years so it averages out.
|
||||
: ''}, and {percent(normalReturn)} in the other years so it averages out{markets.calmUntil !==
|
||||
null
|
||||
? ` (from ${markets.calmUntil + 1})`
|
||||
: ''}. A crash drops over March–October and takes about {Math.ceil(
|
||||
Math.log(1 / (1 + active.crashPercent / 100)) / Math.log(1 + normalReturn)
|
||||
)} years of normal returns to recover.
|
||||
{#if shocks.length}Next ten years: {shocks
|
||||
.map((row) => `${row.year} ${row.kind} ${Math.round(row.rate * 100)}%`)
|
||||
.join(', ')}.{:else}No crash or correction in the next ten years.{/if}
|
||||
{:else}No crashes modelled.{/if}
|
||||
{#if active.salaryCapGrowthPercent > 0}The salary plateau keeps rising {active.salaryCapGrowthPercent}%/yr
|
||||
above inflation, so pay never stalls.{/if}
|
||||
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
|
||||
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
|
||||
the rest of the spending is sold from investments.
|
||||
@@ -180,9 +209,10 @@
|
||||
Joint items from the recurring list in full until the rent starts, then your share, while your
|
||||
partner’s income pays hers, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
|
||||
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
|
||||
grow with inflation; spending grows at the spending rate for ten years, then with inflation.
|
||||
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
|
||||
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
|
||||
grow with inflation (the plateau can grow faster, see above); spending grows at the spending
|
||||
rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
|
||||
Salary bands (base per year, excl. 8% holiday pay): medior €52–68k, senior €65–91k, lead
|
||||
€79–110k (<a
|
||||
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
|
||||
rel="external noreferrer">Chase IT 2026</a
|
||||
>,
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('money tracker calculations', () => {
|
||||
}
|
||||
];
|
||||
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
|
||||
expect(total.dashedFrom).toBe('2026-09-23');
|
||||
expect(total.projectedFrom).toBe('2026-09-23');
|
||||
expect(total.points).toEqual([
|
||||
{ date: '2026-09-01', value: 50000 },
|
||||
{ date: '2026-09-10', value: 70000 },
|
||||
|
||||
@@ -15,8 +15,8 @@ export type ChartSeries = {
|
||||
color: string;
|
||||
points: ChartPoint[];
|
||||
emphasis?: boolean;
|
||||
/** Points after this date are drawn dashed (projected). */
|
||||
dashedFrom?: string;
|
||||
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
|
||||
projectedFrom?: string;
|
||||
};
|
||||
|
||||
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
|
||||
@@ -283,7 +283,7 @@ export function wealthProjection(
|
||||
/**
|
||||
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
|
||||
* date order), so a daily projection over decades keeps its spikes without plotting every day.
|
||||
* The first and last points and the solid/dashed join are always kept.
|
||||
* The first and last points and the actual/projected join are always kept.
|
||||
*/
|
||||
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
|
||||
const limit = Math.max(2, Math.floor(buckets)) * 2;
|
||||
@@ -301,8 +301,8 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
|
||||
const keep = new Uint8Array(line.points.length);
|
||||
keep[0] = 1;
|
||||
keep[line.points.length - 1] = 1;
|
||||
if (line.dashedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
|
||||
if (line.projectedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
|
||||
if (join >= 0) keep[join] = 1;
|
||||
}
|
||||
let bucket = -1;
|
||||
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
|
||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||
|
||||
/**
|
||||
* Joins actual history (solid, through today) with the wealth projection (dashed, after
|
||||
* Joins actual history (solid, through today) with the wealth projection (thin solid line, after
|
||||
* today) so both read as one continuous line per measure.
|
||||
*/
|
||||
export function wealthTimeline(
|
||||
@@ -379,7 +379,7 @@ export function wealthTimeline(
|
||||
label: 'Total',
|
||||
color: 'var(--site-text)',
|
||||
emphasis: true,
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
|
||||
};
|
||||
if (view === 'accounts')
|
||||
@@ -395,13 +395,13 @@ export function wealthTimeline(
|
||||
{
|
||||
label: 'Spendable',
|
||||
color: '#46a758',
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(sumKind(false), spendableProjection?.points ?? [])
|
||||
},
|
||||
{
|
||||
label: 'Invested',
|
||||
color: '#8e4ec6',
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(sumKind(true), investedProjection?.points ?? [])
|
||||
}
|
||||
];
|
||||
|
||||
@@ -189,6 +189,49 @@ describe('runScenario', () => {
|
||||
expect(markets.yearReturn(2034).kind).toBe('correction');
|
||||
expect(markets.yearReturn(2028).kind).toBe('normal');
|
||||
});
|
||||
it('keeps the optimistic start calm and its cycle after that', () => {
|
||||
const markets = marketReturns(defaultScenarios.optimistic, 2026);
|
||||
for (let year = 2026; year <= 2031; year++)
|
||||
expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
|
||||
expect(markets.yearReturn(2032).kind).toBe('normal');
|
||||
expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
|
||||
expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
|
||||
// No down year at all in the first five years of the run.
|
||||
const run = runScenario(inputs, defaultScenarios.optimistic);
|
||||
const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
|
||||
growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
|
||||
});
|
||||
it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
|
||||
const shocks = (id: 'realistic' | 'pessimistic') => {
|
||||
const markets = marketReturns(defaultScenarios[id], 2026);
|
||||
return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
|
||||
.map((year) => ({ year, ...markets.yearReturn(year) }))
|
||||
.filter((row) => row.kind === 'crash');
|
||||
};
|
||||
expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
|
||||
expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
|
||||
expect(defaultScenarios.realistic.calmYears).toBe(0);
|
||||
expect(defaultScenarios.pessimistic.calmYears).toBe(0);
|
||||
// Without a start year the calm start never applies.
|
||||
expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
|
||||
});
|
||||
it('keeps the optimistic salary rising every working year', () => {
|
||||
const long = { ...inputs, endDate: '2076-10-24' };
|
||||
const years = runScenario(long, defaultScenarios.optimistic).years.filter(
|
||||
(row) => row.year >= 2027 && row.role !== 'Retired'
|
||||
);
|
||||
expect(years.length).toBeGreaterThan(40);
|
||||
years
|
||||
.slice(1)
|
||||
.forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
|
||||
// A flat plateau stalls once it is reached.
|
||||
const flatCap = runScenario(long, {
|
||||
...defaultScenarios.optimistic,
|
||||
salaryCapGrowthPercent: 0
|
||||
});
|
||||
const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
|
||||
expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
|
||||
});
|
||||
it('hops jobs, caps the salary and retires at the chosen age', () => {
|
||||
const long = { ...inputs, endDate: '2126-10-24' };
|
||||
const result = runScenario(long, {
|
||||
|
||||
@@ -39,6 +39,11 @@ export type ScenarioParams = {
|
||||
firstCrashYear: number;
|
||||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||||
correctionPercent: number;
|
||||
/**
|
||||
* Calm start: no crash or correction from this year through this many years ahead; those
|
||||
* years earn the plain average return (0 = the crash cycle applies from the start).
|
||||
*/
|
||||
calmYears: number;
|
||||
/** Interest on the cash buffer, percent per year. */
|
||||
cashRatePercent: number;
|
||||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||||
@@ -62,6 +67,8 @@ export type ScenarioParams = {
|
||||
hopGapMonths: number;
|
||||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||||
salaryCapEuros: number;
|
||||
/** Real growth of that plateau per year, percent (0 = flat once reached). */
|
||||
salaryCapGrowthPercent: number;
|
||||
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||||
bonusPercentOfTarget: number;
|
||||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
||||
@@ -97,6 +104,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 8,
|
||||
firstCrashYear: 2027,
|
||||
correctionPercent: -8,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 0,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 3,
|
||||
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 15,
|
||||
hopGapMonths: 3,
|
||||
salaryCapEuros: 75000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 0,
|
||||
sideStartEuros: 100,
|
||||
sideEndEuros: 100,
|
||||
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2029,
|
||||
correctionPercent: -10,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 20,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 95000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 250,
|
||||
sideEndEuros: 500,
|
||||
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2030,
|
||||
correctionPercent: -15,
|
||||
calmYears: 5,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 22,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 150000,
|
||||
salaryCapGrowthPercent: 2,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 300,
|
||||
sideEndEuros: 2500,
|
||||
@@ -396,9 +409,11 @@ function monthEnd(first: string) {
|
||||
|
||||
/**
|
||||
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
|
||||
* return makes the whole cycle average out at the expected return.
|
||||
* return makes the whole cycle average out at the expected return. With `calmYears`, the years
|
||||
* from `startYear` through `startYear + calmYears` have no crash or correction and earn the plain
|
||||
* average return; the cycle keeps its phase after that.
|
||||
*/
|
||||
export function marketReturns(params: ScenarioParams) {
|
||||
export function marketReturns(params: ScenarioParams, startYear?: number) {
|
||||
const pct = (value: number) => value / 100;
|
||||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||||
const crashes = params.crashPercent !== 0;
|
||||
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
|
||||
const normal = crashes
|
||||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||||
: pct(params.returnPercent);
|
||||
const calmUntil =
|
||||
startYear !== undefined && params.calmYears > 0
|
||||
? startYear + Math.round(params.calmYears)
|
||||
: null;
|
||||
return {
|
||||
normal,
|
||||
calmUntil,
|
||||
yearReturn(year: number) {
|
||||
if (calmUntil !== null && year <= calmUntil)
|
||||
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
|
||||
if (!crashes || year < params.firstCrashYear)
|
||||
return { kind: 'normal' as const, rate: normal };
|
||||
const offset = (year - params.firstCrashYear) % every;
|
||||
@@ -444,7 +466,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
const gapStart = addMonths(facts.contractEnd, 1);
|
||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
||||
const markets = marketReturns(params);
|
||||
const markets = marketReturns(params, Number(inputs.today.slice(0, 4)));
|
||||
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||||
const isHopYear = (year: number) =>
|
||||
params.hopEveryYears > 0 &&
|
||||
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
const cap =
|
||||
((params.salaryCapEuros * 100) / 12) *
|
||||
deflator *
|
||||
Math.pow(1 + pct(params.salaryCapGrowthPercent), Math.max(0, index) / 12);
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
// compounding gives the same monthly return.
|
||||
const market = markets.yearReturn(year);
|
||||
const monthRate =
|
||||
market.kind === 'normal'
|
||||
market.kind === 'normal' || market.kind === 'calm'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
@@ -848,7 +873,7 @@ export function scenarioProjection(result: ScenarioResult, todaysEuros = false):
|
||||
];
|
||||
}
|
||||
|
||||
/** One dashed total line per scenario, for comparing them on the wealth chart. */
|
||||
/** One projected total line per scenario, for comparing them on the wealth chart. */
|
||||
export function scenarioCompareLines(
|
||||
results: Record<ScenarioId, ScenarioResult>,
|
||||
params: Record<ScenarioId, ScenarioParams>,
|
||||
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
|
||||
return scenarioIds.map((id) => ({
|
||||
label: params[id].label,
|
||||
color: scenarioColors[id],
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: results[id].points
|
||||
.filter((point) => point.date > today)
|
||||
.map((point) => ({
|
||||
|
||||
1331
src/lib/napkin/MathMap.svelte
Normal file
1331
src/lib/napkin/MathMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
124
src/lib/napkin/map.test.ts
Normal file
124
src/lib/napkin/map.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildMapLayout, MAP_GRID, PART_ACCENT, PART_LINKS } from './map';
|
||||
|
||||
// Chapter counts per part, as in the seeded Napkin.
|
||||
const COUNTS: Record<string, number> = {
|
||||
'1': 3,
|
||||
'2': 3,
|
||||
'3': 3,
|
||||
'4': 7,
|
||||
'5': 3,
|
||||
'6': 4,
|
||||
'7': 3,
|
||||
'8': 5,
|
||||
'9': 4,
|
||||
'10': 5,
|
||||
'11': 3,
|
||||
'12': 4,
|
||||
'13': 6,
|
||||
'14': 6,
|
||||
'15': 5,
|
||||
'16': 3,
|
||||
'17': 4,
|
||||
'18': 6,
|
||||
'19': 5,
|
||||
'20': 6,
|
||||
'21': 4,
|
||||
'22': 3,
|
||||
appendix: 5
|
||||
};
|
||||
let n = 0;
|
||||
const parts = Object.entries(COUNTS).map(([key, count]) => ({
|
||||
key,
|
||||
chapters: Array.from({ length: count }, () => String(++n))
|
||||
}));
|
||||
const layout = buildMapLayout(parts);
|
||||
|
||||
/** Points along an absolute M/L/C path. */
|
||||
function samples(d: string) {
|
||||
const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?/g) ?? [];
|
||||
const pts: [number, number][] = [];
|
||||
let cur: [number, number] = [0, 0];
|
||||
let i = 0;
|
||||
const num = () => Number(tokens[i++]);
|
||||
while (i < tokens.length) {
|
||||
const cmd = tokens[i++];
|
||||
if (cmd === 'M' || cmd === 'L') {
|
||||
const p: [number, number] = [num(), num()];
|
||||
if (cmd === 'L')
|
||||
for (let t = 0; t <= 1; t += 0.05)
|
||||
pts.push([cur[0] + (p[0] - cur[0]) * t, cur[1] + (p[1] - cur[1]) * t]);
|
||||
cur = p;
|
||||
} else if (cmd === 'C') {
|
||||
const [c1, c2, p] = [
|
||||
[num(), num()],
|
||||
[num(), num()],
|
||||
[num(), num()]
|
||||
];
|
||||
for (let t = 0; t <= 1; t += 0.05) {
|
||||
const u = 1 - t;
|
||||
pts.push([
|
||||
u ** 3 * cur[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t ** 3 * p[0],
|
||||
u ** 3 * cur[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t ** 3 * p[1]
|
||||
]);
|
||||
}
|
||||
cur = p as [number, number];
|
||||
} else throw new Error(`unexpected path token ${cmd}`);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
describe('napkin map layout', () => {
|
||||
it('places every part and every chapter', () => {
|
||||
expect(layout.regions.map((r) => r.key).sort()).toEqual(Object.keys(COUNTS).sort());
|
||||
expect(layout.nodes).toHaveLength(n);
|
||||
for (const key of Object.keys(COUNTS)) {
|
||||
expect(MAP_GRID[key]).toBeDefined();
|
||||
expect(PART_ACCENT[key]).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps regions apart and nodes inside their region', () => {
|
||||
const rs = layout.regions;
|
||||
for (let a = 0; a < rs.length; a++)
|
||||
for (let b = a + 1; b < rs.length; b++) {
|
||||
const [p, q] = [rs[a], rs[b]];
|
||||
const overlap = p.x < q.x + q.w && q.x < p.x + p.w && p.y < q.y + q.h && q.y < p.y + p.h;
|
||||
expect(overlap, `${p.key} overlaps ${q.key}`).toBe(false);
|
||||
}
|
||||
for (const node of layout.nodes) {
|
||||
const r = rs.find((x) => x.key === node.partKey)!;
|
||||
expect(node.x).toBeGreaterThanOrEqual(r.x);
|
||||
expect(node.y).toBeGreaterThanOrEqual(r.y);
|
||||
expect(node.x + node.w).toBeLessThanOrEqual(r.x + r.w);
|
||||
expect(node.y + node.h).toBeLessThanOrEqual(r.y + r.h);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws one edge per part link, optional ones flagged', () => {
|
||||
expect(layout.edges).toHaveLength(PART_LINKS.length);
|
||||
expect(layout.edges.filter((e) => e.optional)).toHaveLength(
|
||||
PART_LINKS.filter((l) => l.optional).length
|
||||
);
|
||||
expect(PART_LINKS.filter((l) => l.optional).length).toBe(10);
|
||||
for (const e of layout.edges) expect(e.d).toMatch(/^M/);
|
||||
});
|
||||
|
||||
it('never routes an edge through an unrelated region', () => {
|
||||
for (const e of layout.edges) {
|
||||
const others = layout.regions.filter((r) => r.key !== e.from && r.key !== e.to);
|
||||
for (const [x, y] of samples(e.d))
|
||||
for (const r of others) {
|
||||
const inside = x > r.x + 1 && x < r.x + r.w - 1 && y > r.y + 1 && y < r.y + r.h - 1;
|
||||
expect(inside, `${e.id} crosses part ${r.key} at ${x},${y}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('flows downward from the roots', () => {
|
||||
const row = (k: string) => MAP_GRID[k][1];
|
||||
for (const l of PART_LINKS) expect(row(l.to)).toBeGreaterThanOrEqual(row(l.from) - 1);
|
||||
for (const root of ['2', '3', '4', '21']) expect(row(root)).toBeLessThanOrEqual(1);
|
||||
for (const leaf of ['18', '20', '15']) expect(row(leaf)).toBe(5);
|
||||
});
|
||||
});
|
||||
303
src/lib/napkin/map.ts
Normal file
303
src/lib/napkin/map.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The Napkin as a 2D map: one region per part, one node per chapter, prereq edges between parts.
|
||||
*
|
||||
* Region placement follows the book's dependency chart ("Advice for the reader", PDF p. 9,
|
||||
* tex/frontmatter/digraph.tex): Abs Alg, Lin Alg, Topology and Set Theory are the roots and
|
||||
* everything flows down to Alg Geo, Alg Top and Alg NT. Regions sit on a hand-placed grid; edges
|
||||
* are routed through the empty channels between rows and the gutters between columns, so they
|
||||
* never cut through a region.
|
||||
*/
|
||||
|
||||
/** Part → [column, row] on the map grid. */
|
||||
export const MAP_GRID: Record<string, readonly [number, number]> = {
|
||||
appendix: [0, 0],
|
||||
'1': [3, 0],
|
||||
'21': [0, 1],
|
||||
'2': [1, 1],
|
||||
'4': [3, 1],
|
||||
'3': [5, 1],
|
||||
'22': [0, 2],
|
||||
'5': [1, 2],
|
||||
'6': [2, 2],
|
||||
'7': [4, 2],
|
||||
'8': [5, 2],
|
||||
'17': [2, 3],
|
||||
'12': [4, 3],
|
||||
'9': [5, 3],
|
||||
'10': [6, 3],
|
||||
'19': [1, 4],
|
||||
'16': [2, 4],
|
||||
'14': [3, 4],
|
||||
'13': [4, 4],
|
||||
'11': [6, 4],
|
||||
'20': [1, 5],
|
||||
'18': [2, 5],
|
||||
'15': [3, 5]
|
||||
};
|
||||
|
||||
export type PartLink = { from: string; to: string; optional: boolean };
|
||||
|
||||
/**
|
||||
* Part-level prereqs from the book's chart. `optional` = dotted in the book (\coreqc), solid
|
||||
* otherwise (\prereqc). Additions: Starting Out leads into Abs Alg and Topology, Measure →
|
||||
* Probability and Diff Geo → Riemann Surfaces (one block in the chart each).
|
||||
*/
|
||||
export const PART_LINKS: readonly PartLink[] = (
|
||||
[
|
||||
['1', '2'],
|
||||
['1', '3'],
|
||||
['2', '4', true],
|
||||
['2', '5'],
|
||||
['2', '6'],
|
||||
['4', '6'],
|
||||
['4', '7'],
|
||||
['2', '17'],
|
||||
['4', '17', true],
|
||||
['3', '17', true],
|
||||
['17', '6', true],
|
||||
['3', '8'],
|
||||
['3', '9'],
|
||||
['8', '9', true],
|
||||
['16', '9', true],
|
||||
['3', '10'],
|
||||
['8', '10', true],
|
||||
['10', '11'],
|
||||
['4', '12'],
|
||||
['3', '12'],
|
||||
['8', '12', true],
|
||||
['12', '13'],
|
||||
['2', '14'],
|
||||
['4', '14'],
|
||||
['14', '15'],
|
||||
['5', '15'],
|
||||
['3', '16'],
|
||||
['5', '16'],
|
||||
['16', '17', true],
|
||||
['16', '18'],
|
||||
['17', '18'],
|
||||
['2', '19'],
|
||||
['3', '19'],
|
||||
['17', '19', true],
|
||||
['19', '20'],
|
||||
['17', '20'],
|
||||
['21', '22']
|
||||
] as const
|
||||
).map(([from, to, optional]) => ({ from, to, optional: optional === true }));
|
||||
|
||||
/** Accent per area of maths (region badge stripe), kept apart from the status colours. */
|
||||
export const PART_ACCENT: Record<string, string> = {
|
||||
'1': '#e4e4e7',
|
||||
appendix: '#a1a1aa',
|
||||
'2': '#f472b6',
|
||||
'5': '#f472b6',
|
||||
'6': '#f472b6',
|
||||
'17': '#f472b6',
|
||||
'4': '#818cf8',
|
||||
'7': '#818cf8',
|
||||
'3': '#2dd4bf',
|
||||
'16': '#2dd4bf',
|
||||
'18': '#2dd4bf',
|
||||
'8': '#fb923c',
|
||||
'9': '#fb923c',
|
||||
'10': '#fb923c',
|
||||
'11': '#fb923c',
|
||||
'12': '#fb923c',
|
||||
'13': '#fb923c',
|
||||
'14': '#e879f9',
|
||||
'15': '#e879f9',
|
||||
'19': '#a3e635',
|
||||
'20': '#a3e635',
|
||||
'21': '#94a3b8',
|
||||
'22': '#94a3b8'
|
||||
};
|
||||
|
||||
export const MAP = {
|
||||
regionWidth: 240,
|
||||
gutter: 96,
|
||||
rowGap: 120,
|
||||
head: 46,
|
||||
padX: 10,
|
||||
padBottom: 10,
|
||||
nodeHeight: 34,
|
||||
nodeGap: 6,
|
||||
portGap: 18,
|
||||
laneGap: 12
|
||||
} as const;
|
||||
|
||||
export type MapRegion = {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
col: number;
|
||||
row: number;
|
||||
};
|
||||
export type MapNode = { key: string; partKey: string; x: number; y: number; w: number; h: number };
|
||||
export type MapEdge = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
optional: boolean;
|
||||
d: string;
|
||||
/** End point, for the arrow head. */
|
||||
end: { x: number; y: number; dir: 'down' | 'up' | 'right' };
|
||||
};
|
||||
export type MapLayout = {
|
||||
regions: MapRegion[];
|
||||
nodes: MapNode[];
|
||||
edges: MapEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const r1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function buildMapLayout(
|
||||
parts: readonly { key: string; chapters: readonly string[] }[],
|
||||
links: readonly PartLink[] = PART_LINKS
|
||||
): MapLayout {
|
||||
const M = MAP;
|
||||
const W = M.regionWidth;
|
||||
const colX = (c: number) => c * (W + M.gutter);
|
||||
const heightOf = (n: number) => M.head + n * (M.nodeHeight + M.nodeGap) - M.nodeGap + M.padBottom;
|
||||
|
||||
const placed = parts.filter((p) => MAP_GRID[p.key]);
|
||||
const rows = Math.max(...placed.map((p) => MAP_GRID[p.key][1])) + 1;
|
||||
const cols = Math.max(...placed.map((p) => MAP_GRID[p.key][0])) + 1;
|
||||
const rowHeight = Array.from({ length: rows }, () => 0);
|
||||
for (const p of placed) {
|
||||
const [, r] = MAP_GRID[p.key];
|
||||
rowHeight[r] = Math.max(rowHeight[r], heightOf(p.chapters.length));
|
||||
}
|
||||
const rowTop: number[] = [0];
|
||||
for (let r = 1; r < rows; r++) rowTop[r] = rowTop[r - 1] + rowHeight[r - 1] + M.rowGap;
|
||||
/** Channel between row r and r + 1: [channelTop, channelBottom]. */
|
||||
const chTop = (r: number) => rowTop[r] + rowHeight[r];
|
||||
const chBot = (r: number) => rowTop[r + 1];
|
||||
|
||||
const regions: MapRegion[] = [];
|
||||
const nodes: MapNode[] = [];
|
||||
for (const p of placed) {
|
||||
const [col, row] = MAP_GRID[p.key];
|
||||
const reg = {
|
||||
key: p.key,
|
||||
col,
|
||||
row,
|
||||
x: colX(col),
|
||||
y: rowTop[row],
|
||||
w: W,
|
||||
h: heightOf(p.chapters.length)
|
||||
};
|
||||
regions.push(reg);
|
||||
p.chapters.forEach((key, i) =>
|
||||
nodes.push({
|
||||
key,
|
||||
partKey: p.key,
|
||||
x: reg.x + M.padX,
|
||||
y: reg.y + M.head + i * (M.nodeHeight + M.nodeGap),
|
||||
w: W - 2 * M.padX,
|
||||
h: M.nodeHeight
|
||||
})
|
||||
);
|
||||
}
|
||||
const reg = new Map(regions.map((r) => [r.key, r]));
|
||||
const usable = links.filter((l) => reg.has(l.from) && reg.has(l.to));
|
||||
|
||||
// Ports: spread each region's edges along its bottom (outgoing down / incoming up) and top.
|
||||
type Side = 'top' | 'bottom';
|
||||
const portLists = new Map<string, { id: string; other: number }[]>();
|
||||
const addPort = (key: string, side: Side, id: string, other: number) => {
|
||||
const k = `${key}:${side}`;
|
||||
if (!portLists.has(k)) portLists.set(k, []);
|
||||
portLists.get(k)!.push({ id, other });
|
||||
};
|
||||
const idOf = (l: PartLink) => `${l.from}-${l.to}`;
|
||||
for (const l of usable) {
|
||||
const a = reg.get(l.from)!;
|
||||
const b = reg.get(l.to)!;
|
||||
if (a.row === b.row) continue;
|
||||
const down = b.row > a.row;
|
||||
addPort(a.key, down ? 'bottom' : 'top', idOf(l), b.x + b.w / 2);
|
||||
addPort(b.key, down ? 'top' : 'bottom', idOf(l), a.x + a.w / 2);
|
||||
}
|
||||
const portX = (key: string, side: Side, id: string) => {
|
||||
const r = reg.get(key)!;
|
||||
const list = [...portLists.get(`${key}:${side}`)!].sort(
|
||||
(p, q) => p.other - q.other || (p.id < q.id ? -1 : 1)
|
||||
);
|
||||
const i = list.findIndex((p) => p.id === id);
|
||||
const gap = Math.min(M.portGap, (r.w * 0.7) / Math.max(1, list.length));
|
||||
return r.x + r.w / 2 + (i - (list.length - 1) / 2) * gap;
|
||||
};
|
||||
/** Is x free of regions in every row strictly between a and b? */
|
||||
const freeAt = (x: number, a: number, b: number) =>
|
||||
regions.every((r) => r.row <= a || r.row >= b || x < r.x - 14 || x > r.x + r.w + 14);
|
||||
const gutterLoad = new Map<number, number>();
|
||||
const s = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
if (Math.abs(x0 - x1) < 0.5) return ` L${r1(x1)},${r1(y1)}`;
|
||||
const m = (y0 + y1) / 2;
|
||||
return ` C${r1(x0)},${r1(m)} ${r1(x1)},${r1(m)} ${r1(x1)},${r1(y1)}`;
|
||||
};
|
||||
|
||||
const edges: MapEdge[] = [];
|
||||
for (const l of usable) {
|
||||
const a = reg.get(l.from)!;
|
||||
const b = reg.get(l.to)!;
|
||||
const id = idOf(l);
|
||||
let d: string;
|
||||
let end: MapEdge['end'];
|
||||
if (a.row === b.row) {
|
||||
// Same row (Abs Alg ⇢ Lin Alg): straight across at header height.
|
||||
const y = a.y + 23;
|
||||
const [x0, x1] = a.x < b.x ? [a.x + a.w, b.x] : [a.x, b.x + b.w];
|
||||
d = `M${r1(x0)},${r1(y)} L${r1(x1)},${r1(y)}`;
|
||||
end = { x: x1, y, dir: 'right' };
|
||||
} else if (b.row < a.row) {
|
||||
// Upward (optional back-links, adjacent rows only).
|
||||
const sx = portX(a.key, 'top', id);
|
||||
const tx = portX(b.key, 'bottom', id);
|
||||
const ty = b.y + b.h;
|
||||
d = `M${r1(sx)},${r1(a.y)}` + s(sx, a.y, tx, chTop(b.row)) + ` L${r1(tx)},${r1(ty)}`;
|
||||
end = { x: tx, y: ty, dir: 'up' };
|
||||
} else {
|
||||
const sx = portX(a.key, 'bottom', id);
|
||||
const tx = portX(b.key, 'top', id);
|
||||
const sy = a.y + a.h;
|
||||
const ty = b.y;
|
||||
d = `M${r1(sx)},${r1(sy)} L${r1(sx)},${r1(chTop(a.row))}`;
|
||||
if (b.row === a.row + 1) d += s(sx, chTop(a.row), tx, ty);
|
||||
else if (freeAt(tx, a.row, b.row))
|
||||
d += s(sx, chTop(a.row), tx, chBot(a.row)) + ` L${r1(tx)},${r1(ty)}`;
|
||||
else if (freeAt(sx, a.row, b.row))
|
||||
d += ` L${r1(sx)},${r1(chTop(b.row - 1))}` + s(sx, chTop(b.row - 1), tx, ty);
|
||||
else {
|
||||
// Down a gutter between columns: the one with the least detour, then the least used.
|
||||
let best = { g: 0, cost: Infinity };
|
||||
for (let g = 0; g <= cols; g++) {
|
||||
const gx = colX(g) - M.gutter / 2;
|
||||
const cost = Math.abs(sx - gx) + Math.abs(tx - gx) + (gutterLoad.get(g) ?? 0) * 40;
|
||||
if (cost < best.cost) best = { g, cost };
|
||||
}
|
||||
const n = gutterLoad.get(best.g) ?? 0;
|
||||
gutterLoad.set(best.g, n + 1);
|
||||
const lane = (n % 2 ? -1 : 1) * Math.ceil(n / 2) * M.laneGap;
|
||||
const gx = colX(best.g) - M.gutter / 2 + lane;
|
||||
d +=
|
||||
s(sx, chTop(a.row), gx, chBot(a.row)) +
|
||||
` L${r1(gx)},${r1(chTop(b.row - 1))}` +
|
||||
s(gx, chTop(b.row - 1), tx, ty);
|
||||
}
|
||||
end = { x: tx, y: ty, dir: 'down' };
|
||||
}
|
||||
edges.push({ id, from: l.from, to: l.to, optional: l.optional, d, end });
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
nodes,
|
||||
edges,
|
||||
width: colX(cols) - M.gutter,
|
||||
height: rowTop[rows - 1] + rowHeight[rows - 1]
|
||||
};
|
||||
}
|
||||
79
src/lib/plant.test.ts
Normal file
79
src/lib/plant.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
KIND_TAB,
|
||||
PLANT_KINDS,
|
||||
entryHref,
|
||||
kindFromSegment,
|
||||
kindFromTab,
|
||||
layoutFlow,
|
||||
matches,
|
||||
slugify,
|
||||
tabHref
|
||||
} from './plant';
|
||||
|
||||
describe('plant tracker', () => {
|
||||
it('lays the flow out top to bottom, ignoring dashed loops', () => {
|
||||
// oil → clean → split → col → dimer, dimer ⇢ col (dashed), clean ⇢ ester, col → ester
|
||||
const nodes = [1, 2, 3, 4, 5, 6].map((id) => ({ id, sort: id }));
|
||||
const edges = [
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 3, dashed: false },
|
||||
{ from: 3, to: 4, dashed: false },
|
||||
{ from: 4, to: 5, dashed: false },
|
||||
{ from: 5, to: 4, dashed: true },
|
||||
{ from: 2, to: 6, dashed: true },
|
||||
{ from: 4, to: 6, dashed: false }
|
||||
];
|
||||
const { placed, width, height } = layoutFlow(nodes, edges);
|
||||
const layer = Object.fromEntries(placed.map((p) => [p.id, p.layer]));
|
||||
expect(layer).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 4 });
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('survives a solid cycle', () => {
|
||||
const { placed } = layoutFlow(
|
||||
[
|
||||
{ id: 1, sort: 0 },
|
||||
{ id: 2, sort: 1 }
|
||||
],
|
||||
[
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 1, dashed: false }
|
||||
]
|
||||
);
|
||||
expect(placed.map((p) => p.layer)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('maps kinds to URLs and back', () => {
|
||||
expect(entryHref({ kind: 'process_step', slug: 'splitting' })).toBe(
|
||||
'/trackers/cargill/process-step/splitting'
|
||||
);
|
||||
expect(kindFromSegment('process-step')).toBe('process_step');
|
||||
expect(kindFromSegment('nope')).toBeNull();
|
||||
expect(slugify('Lab (QC) – Ëster')).toBe('lab-qc-ester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs and search', () => {
|
||||
it('maps kinds to tabs and back', () => {
|
||||
for (const k of PLANT_KINDS) expect(kindFromTab(KIND_TAB[k])).toBe(k);
|
||||
expect(kindFromTab('nope')).toBeNull();
|
||||
expect(kindFromTab(null)).toBeNull();
|
||||
expect(tabHref('process_step')).toBe('/trackers/cargill?tab=process');
|
||||
expect(tabHref(null)).toBe('/trackers/cargill');
|
||||
});
|
||||
it('matches all words, ignoring case and accents', () => {
|
||||
const e = {
|
||||
title: 'Dimer acids',
|
||||
slug: 'dimer-acids',
|
||||
summary: 'Twee vetzuren',
|
||||
body: null,
|
||||
tags: ['product']
|
||||
};
|
||||
expect(matches(e, 'DIMER twee')).toBe(true);
|
||||
expect(matches(e, 'dimer ester')).toBe(false);
|
||||
expect(matches({ ...e, title: 'Crème' }, 'creme')).toBe(true);
|
||||
expect(matches(e, ' ')).toBe(true);
|
||||
});
|
||||
});
|
||||
184
src/lib/plant.ts
Normal file
184
src/lib/plant.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Cargill plant tracker (/trackers/cargill): what the Cargill Bioindustrial plant does.
|
||||
// Shared by the server (loads, validation) and the pages. Owner-only: the whole path is in
|
||||
// ownerOnlyPrefixes (#lib/server/owner), so everyone else gets a 404.
|
||||
|
||||
/** Entry kinds (kept in sync with the entries_kind_check constraint in the plant migration). */
|
||||
export const PLANT_KINDS = [
|
||||
'process_step',
|
||||
'product',
|
||||
'department',
|
||||
'equipment',
|
||||
'glossary',
|
||||
'question',
|
||||
'note',
|
||||
'person_role'
|
||||
] as const;
|
||||
export type PlantKind = (typeof PLANT_KINDS)[number];
|
||||
|
||||
export const KIND_LABEL: Record<PlantKind, { one: string; many: string }> = {
|
||||
process_step: { one: 'process step', many: 'process' },
|
||||
product: { one: 'product', many: 'products' },
|
||||
department: { one: 'department', many: 'departments' },
|
||||
equipment: { one: 'equipment', many: 'equipment' },
|
||||
glossary: { one: 'glossary word', many: 'glossary' },
|
||||
question: { one: 'question', many: 'questions' },
|
||||
note: { one: 'note', many: 'notes' },
|
||||
person_role: { one: 'role', many: 'roles' }
|
||||
};
|
||||
|
||||
/** Tile gradient per kind (colourful icons on a black-and-white page). */
|
||||
export const KIND_COLOR: Record<PlantKind, readonly [string, string]> = {
|
||||
process_step: ['#a78bfa', '#6d28d9'],
|
||||
product: ['#fcd34d', '#d97706'],
|
||||
department: ['#7dd3fc', '#0369a1'],
|
||||
equipment: ['#fdba74', '#c2410c'],
|
||||
glossary: ['#6ee7b7', '#047857'],
|
||||
question: ['#f9a8d4', '#be185d'],
|
||||
note: ['#d4d4d8', '#52525b'],
|
||||
person_role: ['#93c5fd', '#1d4ed8']
|
||||
};
|
||||
|
||||
/** confirmed: checked at work. assumed: from the guide / general knowledge. open: unknown. */
|
||||
export const CONFIDENCES = ['confirmed', 'assumed', 'open'] as const;
|
||||
export type Confidence = (typeof CONFIDENCES)[number];
|
||||
|
||||
export const RELATIONS = ['feeds_into', 'made_in', 'uses', 'part_of', 'related'] as const;
|
||||
export type Relation = (typeof RELATIONS)[number];
|
||||
|
||||
/** How a link reads from each end: "A feeds into B" / "B is fed by A". */
|
||||
export const RELATION_LABEL: Record<Relation, { out: string; in: string }> = {
|
||||
feeds_into: { out: 'feeds into', in: 'fed by' },
|
||||
made_in: { out: 'made in', in: 'makes' },
|
||||
uses: { out: 'uses', in: 'used by' },
|
||||
part_of: { out: 'part of', in: 'includes' },
|
||||
related: { out: 'related', in: 'related' }
|
||||
};
|
||||
|
||||
export const slugify = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
export const entryHref = (e: { kind: string; slug: string }) =>
|
||||
`/trackers/cargill/${e.kind.replace('_', '-')}/${e.slug}`;
|
||||
/** URL segment ("process-step") back to the kind ("process_step"). */
|
||||
export const kindFromSegment = (seg: string): PlantKind | null => {
|
||||
const k = seg.replace(/-/g, '_');
|
||||
return (PLANT_KINDS as readonly string[]).includes(k) ? (k as PlantKind) : null;
|
||||
};
|
||||
|
||||
// ---- Process flow layout (pure, unit-tested) -------------------------------------------
|
||||
|
||||
export type FlowNode = { id: number; sort: number };
|
||||
export type FlowEdge = { from: number; to: number; dashed: boolean };
|
||||
export type Placed = { id: number; layer: number; x: number; y: number };
|
||||
|
||||
export const FLOW = { w: 160, h: 50, gapX: 16, gapY: 46, pad: 8 } as const;
|
||||
|
||||
/**
|
||||
* Layered top-to-bottom layout. Layers are the longest path over solid edges (dashed ones,
|
||||
* like "sort again" loops, don't push nodes down; real cycles are broken by DFS order).
|
||||
* Within a layer nodes sit near their parents (barycentre), ties by sort order.
|
||||
*/
|
||||
export function layoutFlow(nodes: FlowNode[], edges: FlowEdge[]) {
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const solid = edges.filter((e) => !e.dashed && ids.has(e.from) && ids.has(e.to));
|
||||
const sort = new Map(nodes.map((n) => [n.id, n.sort]));
|
||||
const out = new Map<number, number[]>();
|
||||
for (const e of solid) out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
|
||||
// Drop back edges (cycles) with a DFS in sort order.
|
||||
const state = new Map<number, 1 | 2>();
|
||||
const keep: FlowEdge[] = [];
|
||||
const order = [...nodes].sort((a, b) => a.sort - b.sort || a.id - b.id);
|
||||
const visit = (id: number) => {
|
||||
state.set(id, 1);
|
||||
for (const to of out.get(id) ?? []) {
|
||||
if (state.get(to) === 1) continue; // back edge
|
||||
keep.push({ from: id, to, dashed: false });
|
||||
if (!state.has(to)) visit(to);
|
||||
}
|
||||
state.set(id, 2);
|
||||
};
|
||||
for (const n of order) if (!state.has(n.id)) visit(n.id);
|
||||
const layer = new Map(nodes.map((n) => [n.id, 0]));
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let changed = false;
|
||||
for (const e of keep) {
|
||||
const l = layer.get(e.from)! + 1;
|
||||
if (l > layer.get(e.to)!) {
|
||||
layer.set(e.to, l);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
const layers: number[][] = [];
|
||||
for (const n of order) (layers[layer.get(n.id)!] ??= []).push(n.id);
|
||||
const xIndex = new Map<number, number>();
|
||||
const parents = new Map<number, number[]>();
|
||||
for (const e of keep) parents.set(e.to, [...(parents.get(e.to) ?? []), e.from]);
|
||||
for (const row of layers) {
|
||||
if (!row) continue;
|
||||
const bary = (id: number) => {
|
||||
const ps = (parents.get(id) ?? []).filter((p) => xIndex.has(p));
|
||||
return ps.length ? ps.reduce((s, p) => s + xIndex.get(p)!, 0) / ps.length : Infinity;
|
||||
};
|
||||
row.sort((a, b) => {
|
||||
const d = bary(a) - bary(b);
|
||||
return Number.isFinite(d) && d !== 0 ? d : sort.get(a)! - sort.get(b)! || a - b;
|
||||
});
|
||||
row.forEach((id, i) => xIndex.set(id, i - (row.length - 1) / 2));
|
||||
}
|
||||
const widest = Math.max(1, ...layers.map((r) => r?.length ?? 0));
|
||||
const width = widest * FLOW.w + (widest - 1) * FLOW.gapX + 2 * FLOW.pad;
|
||||
const rows = layers.length;
|
||||
const height = rows * FLOW.h + Math.max(0, rows - 1) * FLOW.gapY + 2 * FLOW.pad;
|
||||
const placed: Placed[] = nodes.map((n) => {
|
||||
const l = layer.get(n.id)!;
|
||||
return {
|
||||
id: n.id,
|
||||
layer: l,
|
||||
x: width / 2 + xIndex.get(n.id)! * (FLOW.w + FLOW.gapX) - FLOW.w / 2,
|
||||
y: FLOW.pad + l * (FLOW.h + FLOW.gapY)
|
||||
};
|
||||
});
|
||||
return { placed, width, height };
|
||||
}
|
||||
|
||||
// ---- Tabs ------------------------------------------------------------------------------
|
||||
|
||||
/** ?tab= value per kind on /trackers/cargill (no tab = the overview). */
|
||||
export const KIND_TAB: Record<PlantKind, string> = {
|
||||
process_step: 'process',
|
||||
product: 'products',
|
||||
department: 'departments',
|
||||
equipment: 'equipment',
|
||||
glossary: 'glossary',
|
||||
question: 'questions',
|
||||
note: 'notes',
|
||||
person_role: 'roles'
|
||||
};
|
||||
export const kindFromTab = (tab: string | null): PlantKind | null =>
|
||||
(Object.entries(KIND_TAB).find(([, t]) => t === tab)?.[0] as PlantKind | undefined) ?? null;
|
||||
export const tabHref = (kind: PlantKind | null) =>
|
||||
kind ? `/trackers/cargill?tab=${KIND_TAB[kind]}` : '/trackers/cargill';
|
||||
|
||||
/** Case- and accent-insensitive match over title, slug, summary, tags and body. */
|
||||
export function matches(
|
||||
e: { title: string; slug: string; summary: string | null; body: string | null; tags: string[] },
|
||||
q: string
|
||||
) {
|
||||
const norm = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
const words = norm(q).split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = norm([e.title, e.slug, e.summary ?? '', e.tags.join(' '), e.body ?? ''].join(' '));
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
46
src/lib/plant/ConfidencePill.svelte
Normal file
46
src/lib/plant/ConfidencePill.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Confidence } from '#lib/plant.js';
|
||||
let { value }: { value: Confidence } = $props();
|
||||
const TITLE: Record<Confidence, string> = {
|
||||
confirmed: 'Confirmed: checked or certain',
|
||||
assumed: 'Assumed: from the guide, not yet checked at work',
|
||||
open: 'Open: still unknown'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="pill {value}" title={TITLE[value]}>{value}</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
padding: 0.08rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill::before {
|
||||
content: '';
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
.confirmed {
|
||||
color: #10b981;
|
||||
border-color: rgb(16 185 129 / 0.4);
|
||||
}
|
||||
.assumed {
|
||||
color: #f59e0b;
|
||||
border-color: rgb(245 158 11 / 0.4);
|
||||
}
|
||||
.open {
|
||||
color: #ec4899;
|
||||
border-color: rgb(236 72 153 / 0.4);
|
||||
}
|
||||
</style>
|
||||
242
src/lib/plant/EntryForm.svelte
Normal file
242
src/lib/plant/EntryForm.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
// Owner form to add or edit one entry (markdown body). Posts to ?/save on the current page;
|
||||
// both /trackers/cargill pages share the actions (#lib/server/plant-actions).
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CONFIDENCES, KIND_LABEL, PLANT_KINDS, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: string;
|
||||
};
|
||||
let {
|
||||
entry,
|
||||
kind = 'note',
|
||||
entries,
|
||||
onclose,
|
||||
openAfterCreate = false
|
||||
}: {
|
||||
entry?: Entry;
|
||||
kind?: PlantKind;
|
||||
entries: Pick<Entry, 'id' | 'kind' | 'title'>[];
|
||||
onclose: () => void;
|
||||
/** After adding, go to the new entry's page (used by the generic "add entry" form). */
|
||||
openAfterCreate?: boolean;
|
||||
} = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let chosenKind = $state<PlantKind>(entry?.kind ?? kind);
|
||||
const defaultConfidence = $derived(chosenKind === 'question' ? 'open' : 'assumed');
|
||||
|
||||
const submit: SubmitFunction = () => {
|
||||
busy = true;
|
||||
error = null;
|
||||
return async ({ result, update }) => {
|
||||
busy = false;
|
||||
if (result.type === 'failure') {
|
||||
error = String(result.data?.message ?? 'Could not save');
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success') {
|
||||
const href = result.data?.href as string | undefined;
|
||||
const here = location.pathname;
|
||||
if (href && !entry && openAfterCreate)
|
||||
await goto(resolve(href as '/'), { refreshAll: true });
|
||||
// Editing on the entry's own page may change its slug or kind: follow it.
|
||||
else if (href && entry && here.startsWith('/trackers/cargill/') && here !== href)
|
||||
await goto(resolve(href as '/'), { replace: true, refreshAll: true });
|
||||
else await update({ reset: false, navigate: false });
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
await update({ navigate: false });
|
||||
};
|
||||
};
|
||||
const parents = $derived(entries.filter((e) => e.id !== entry?.id));
|
||||
</script>
|
||||
|
||||
<form class="edit" method="POST" action="?/save" use:enhance={submit}>
|
||||
{#if entry}<input type="hidden" name="id" value={entry.id} />{/if}
|
||||
<label
|
||||
>kind<select name="kind" bind:value={chosenKind}>
|
||||
{#each PLANT_KINDS as k (k)}<option value={k}>{KIND_LABEL[k].one}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="span2"
|
||||
>title<input
|
||||
name="title"
|
||||
value={entry?.title ?? ''}
|
||||
maxlength="200"
|
||||
required
|
||||
placeholder={chosenKind === 'question' ? 'What do I want to know?' : 'Name'}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>confidence<select name="confidence" value={entry?.confidence ?? defaultConfidence}>
|
||||
{#each CONFIDENCES as c (c)}<option value={c}>{c}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="wide"
|
||||
>summary<input
|
||||
name="summary"
|
||||
value={entry?.summary ?? ''}
|
||||
maxlength="500"
|
||||
placeholder="one line"
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>details (markdown)<textarea name="body" rows="7" value={entry?.body ?? ''} maxlength="50000"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="span2"
|
||||
>tags<input
|
||||
name="tags"
|
||||
value={entry?.tags.join(', ') ?? ''}
|
||||
maxlength="400"
|
||||
placeholder="comma, separated"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>part of<select name="parent_id" value={entry?.parentId ?? ''}>
|
||||
<option value="">—</option>
|
||||
{#each parents as p (p.id)}<option value={p.id}>{p.title} · {KIND_LABEL[p.kind].one}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>slug<input
|
||||
name="slug"
|
||||
value={entry?.slug ?? ''}
|
||||
maxlength="80"
|
||||
placeholder="from title"
|
||||
pattern="[a-z0-9\-]*"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>order<input
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value={entry?.sortOrder ?? ''}
|
||||
placeholder="0"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>source<input
|
||||
name="source"
|
||||
value={entry?.source ?? ''}
|
||||
maxlength="200"
|
||||
placeholder="where this came from"
|
||||
/></label
|
||||
>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={onclose}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{entry ? 'save' : 'add'}</button>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.edit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
.span2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
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;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
height: 2.1rem;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.primary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.edit {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
src/lib/plant/FlowDiagram.svelte
Normal file
256
src/lib/plant/FlowDiagram.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
// The plant's process flow, drawn from feeds_into links: every entry on such a link is a
|
||||
// node (process steps as cards, raw materials / outputs as dashed pills), laid out top to
|
||||
// bottom by layoutFlow (#lib/plant). Dashed links are optional paths or loops back.
|
||||
import { FLOW, KIND_COLOR, entryHref, layoutFlow, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Node = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
type Link = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: string;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
confidence?: string;
|
||||
};
|
||||
let { entries, links }: { entries: Node[]; links: Link[] } = $props();
|
||||
|
||||
const flow = $derived.by(() => {
|
||||
// Only the process: feeds_into links that touch a process step (not product -> department).
|
||||
const kindOf = new Map(entries.map((e) => [e.id, e.kind]));
|
||||
const feeds = links.filter(
|
||||
(l) =>
|
||||
l.relation === 'feeds_into' &&
|
||||
(kindOf.get(l.fromId) === 'process_step' || kindOf.get(l.toId) === 'process_step')
|
||||
);
|
||||
const ids = new Set(feeds.flatMap((l) => [l.fromId, l.toId]));
|
||||
// Order: process steps by their sort order; inputs first, outputs last.
|
||||
const nodes = entries
|
||||
.filter((e) => ids.has(e.id))
|
||||
.map((e) => ({
|
||||
...e,
|
||||
sort:
|
||||
(e.kind === 'process_step' ? 1000 : feeds.some((l) => l.toId === e.id) ? 5000 : 0) +
|
||||
e.sortOrder
|
||||
}));
|
||||
const { placed, width, height } = layoutFlow(
|
||||
nodes.map((n) => ({ id: n.id, sort: n.sort })),
|
||||
feeds.map((l) => ({ from: l.fromId, to: l.toId, dashed: l.dashed }))
|
||||
);
|
||||
const pos = new Map(placed.map((p) => [p.id, p]));
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
// Spread several edges over a node's bottom (out) and top (in) edge.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const outs = new Map<number, Link[]>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const ins = new Map<number, Link[]>();
|
||||
for (const l of feeds) {
|
||||
outs.set(l.fromId, [...(outs.get(l.fromId) ?? []), l]);
|
||||
ins.set(l.toId, [...(ins.get(l.toId) ?? []), l]);
|
||||
}
|
||||
const port = (list: Link[], l: Link, x: (o: Link) => number) => {
|
||||
const sorted = [...list].sort((a, b) => x(a) - x(b));
|
||||
const i = sorted.indexOf(l);
|
||||
return (i - (sorted.length - 1) / 2) * Math.min(22, (FLOW.w - 30) / sorted.length);
|
||||
};
|
||||
const cx = (id: number) => pos.get(id)!.x + FLOW.w / 2;
|
||||
const placedNodes = placed.map((p) => ({ ...p, node: byId.get(p.id)! }));
|
||||
const mid = width / 2;
|
||||
const M = 12; // clearance between a lane and a node
|
||||
let minX = 0;
|
||||
let maxX = width;
|
||||
const edges = feeds.map((l) => {
|
||||
const a = pos.get(l.fromId)!;
|
||||
const b = pos.get(l.toId)!;
|
||||
let d: string;
|
||||
let lx: number;
|
||||
let ly: number;
|
||||
if (b.layer > a.layer) {
|
||||
const x1 = cx(l.fromId) + port(outs.get(l.fromId)!, l, (o) => cx(o.toId));
|
||||
const y1 = a.y + FLOW.h;
|
||||
const x2 = cx(l.toId) + port(ins.get(l.toId)!, l, (o) => cx(o.fromId));
|
||||
const y2 = b.y - 4;
|
||||
// Nodes on the layers in between that a straight-ish line would cross.
|
||||
const between = placed.filter((p) => p.layer > a.layer && p.layer < b.layer);
|
||||
const hits = (x: number) => between.some((p) => x > p.x - M && x < p.x + FLOW.w + M);
|
||||
const lo = Math.min(x1, x2);
|
||||
const hi = Math.max(x1, x2);
|
||||
const crossed = between.filter((p) => p.x + FLOW.w + M > lo && p.x - M < hi);
|
||||
if (!crossed.length) {
|
||||
const my = (y2 - y1) * 0.5;
|
||||
d = `M${x1} ${y1} C${x1} ${y1 + my} ${x2} ${y2 - my} ${x2} ${y2}`;
|
||||
lx = (x1 + x2) / 2;
|
||||
ly = (y1 + y2) / 2;
|
||||
} else {
|
||||
// Long edge: run down a free vertical lane beside the nodes it would cross.
|
||||
let right = Math.max(...crossed.map((p) => p.x + FLOW.w)) + M;
|
||||
while (hits(right)) right += FLOW.gapX;
|
||||
let left = Math.min(...crossed.map((p) => p.x)) - M;
|
||||
while (hits(left)) left -= FLOW.gapX;
|
||||
const cost = (x: number) => Math.abs(x - x1) + Math.abs(x - x2);
|
||||
const lane = cost(left) < cost(right) ? left : right;
|
||||
const g = FLOW.gapY / 2;
|
||||
const ya = y1 + g;
|
||||
const yb = y2 - g;
|
||||
d =
|
||||
`M${x1} ${y1} C${x1} ${y1 + g * 0.7} ${lane} ${ya - g * 0.7} ${lane} ${ya}` +
|
||||
` L${lane} ${yb} C${lane} ${yb + g * 0.7} ${x2} ${y2 - g * 0.7} ${x2} ${y2}`;
|
||||
lx = lane;
|
||||
ly = (ya + yb) / 2;
|
||||
minX = Math.min(minX, lane - 40);
|
||||
maxX = Math.max(maxX, lane + 40);
|
||||
}
|
||||
} else {
|
||||
// Loop back (or sideways): out of the outer side and back in on the same side.
|
||||
const leftSide = a.x + FLOW.w / 2 + (b.x + FLOW.w / 2) < 2 * mid;
|
||||
const x1 = leftSide ? a.x : a.x + FLOW.w;
|
||||
const x2 = leftSide ? b.x - 4 : b.x + FLOW.w + 4;
|
||||
const y1 = a.y + FLOW.h / 2;
|
||||
const y2 = b.y + FLOW.h / 2;
|
||||
const bulge = 30 + Math.abs(y1 - y2) * 0.1;
|
||||
const out = leftSide ? Math.min(x1, x2) - bulge : Math.max(x1, x2) + bulge;
|
||||
d = `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;
|
||||
lx = leftSide ? Math.min(x1, x2) - bulge * 0.75 : Math.max(x1, x2) + bulge * 0.75;
|
||||
ly = (y1 + y2) / 2;
|
||||
minX = Math.min(minX, out - 30);
|
||||
maxX = Math.max(maxX, out + 30);
|
||||
}
|
||||
return { ...l, d, lx, ly };
|
||||
});
|
||||
return { nodes: placedNodes, edges, minX, width: maxX - minX, height };
|
||||
});
|
||||
const clip = (s: string | null, n: number) =>
|
||||
!s ? '' : s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
||||
</script>
|
||||
|
||||
{#if flow.nodes.length}
|
||||
<svg
|
||||
class="flow"
|
||||
viewBox="{flow.minX} 0 {flow.width} {flow.height}"
|
||||
style:max-width="{flow.width}px"
|
||||
role="img"
|
||||
aria-label="Process flow of the plant"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="plant-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M1 1L9 5L1 9z" class="arrowhead" />
|
||||
</marker>
|
||||
</defs>
|
||||
{#each flow.edges as e (e.id)}
|
||||
<path
|
||||
class={['edge', { dashed: e.dashed || (e.confidence ?? 'confirmed') !== 'confirmed' }]}
|
||||
d={e.d}
|
||||
marker-end="url(#plant-arrow)"
|
||||
/>
|
||||
{/each}
|
||||
{#each flow.edges as e (e.id)}
|
||||
{#if e.label}
|
||||
{@const w = e.label.length * 5.4 + 10}
|
||||
<g class="elabel" transform="translate({e.lx} {e.ly})">
|
||||
<rect x={-w / 2} y="-8" width={w} height="16" rx="8" />
|
||||
<text text-anchor="middle" dy="3.5">{e.label}</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each flow.nodes as p (p.id)}
|
||||
{@const n = p.node}
|
||||
{@const step = n.kind === 'process_step'}
|
||||
<a href={entryHref(n)} class="node-link">
|
||||
<g class={['node', { step, terminal: !step }]} transform="translate({p.x} {p.y})">
|
||||
<title>{n.title}{n.summary ? ` — ${n.summary}` : ''}</title>
|
||||
<rect width={FLOW.w} height={FLOW.h} rx={step ? 10 : 25} />
|
||||
<circle cx="14" cy={FLOW.h / 2} r="4.5" fill={KIND_COLOR[n.kind][1]} />
|
||||
<text class="t" x="25" y={n.summary ? 21 : 29}>{clip(n.title, 20)}</text>
|
||||
{#if n.summary}<text class="s" x="25" y="36">{clip(n.summary, 26)}</text>{/if}
|
||||
</g>
|
||||
</a>
|
||||
{/each}
|
||||
</svg>
|
||||
{:else}
|
||||
<p class="empty">No flow yet: link process steps with “feeds into”.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--site-muted);
|
||||
stroke-opacity: 0.7;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.edge.dashed {
|
||||
stroke-dasharray: 4 4;
|
||||
stroke-opacity: 0.55;
|
||||
}
|
||||
.arrowhead {
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.elabel rect {
|
||||
fill: var(--site-bg);
|
||||
stroke: var(--site-border);
|
||||
}
|
||||
.elabel text {
|
||||
font-size: 9.5px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.node rect {
|
||||
fill: var(--site-surface);
|
||||
stroke: var(--site-border);
|
||||
stroke-width: 1.2;
|
||||
transition:
|
||||
stroke 0.15s,
|
||||
fill 0.15s;
|
||||
}
|
||||
.node.terminal rect {
|
||||
fill: var(--site-bg);
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.node-link:hover rect,
|
||||
.node-link:focus-visible rect {
|
||||
stroke: var(--site-text);
|
||||
}
|
||||
.node-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.t {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
fill: var(--site-text);
|
||||
}
|
||||
.s {
|
||||
font-size: 10px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* Keep the labels readable on phones: the card scrolls sideways instead. */
|
||||
.flow {
|
||||
min-width: 30rem;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
51
src/lib/plant/KindIcon.svelte
Normal file
51
src/lib/plant/KindIcon.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
// A small gradient tile with the kind's icon (colourful icons on a black-and-white page).
|
||||
import { KIND_COLOR, KIND_LABEL, type PlantKind } from '#lib/plant.js';
|
||||
import Workflow from 'lucide-svelte/icons/workflow';
|
||||
import Package from 'lucide-svelte/icons/package';
|
||||
import Building2 from 'lucide-svelte/icons/building-2';
|
||||
import Wrench from 'lucide-svelte/icons/wrench';
|
||||
import BookA from 'lucide-svelte/icons/book-a';
|
||||
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import UserRound from 'lucide-svelte/icons/user-round';
|
||||
|
||||
let { kind, size = 22 }: { kind: PlantKind; size?: number } = $props();
|
||||
|
||||
const ICONS = {
|
||||
process_step: Workflow,
|
||||
product: Package,
|
||||
department: Building2,
|
||||
equipment: Wrench,
|
||||
glossary: BookA,
|
||||
question: CircleQuestionMark,
|
||||
note: StickyNote,
|
||||
person_role: UserRound
|
||||
} as const;
|
||||
const Icon = $derived(ICONS[kind] ?? StickyNote);
|
||||
const colors = $derived(KIND_COLOR[kind] ?? KIND_COLOR.note);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="kind-icon"
|
||||
style:--from={colors[0]}
|
||||
style:--to={colors[1]}
|
||||
style:--size="{size}px"
|
||||
title={KIND_LABEL[kind]?.one ?? kind}
|
||||
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
|
||||
>
|
||||
|
||||
<style>
|
||||
.kind-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: calc(var(--size) * 0.3);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -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,20 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||
external: false,
|
||||
private: true
|
||||
},
|
||||
{
|
||||
name: 'cargill',
|
||||
subtitle: 'what the Cargill Bioindustrial plant in Gouda does: process, products and words',
|
||||
href: '/trackers/cargill',
|
||||
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
217
src/lib/server/ai-setup.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
|
||||
// each with entries grouped by section. Labels only: values that look like secrets are refused.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
AI_SETUP_COLORS,
|
||||
AI_SETUP_SCOPES,
|
||||
AI_SETUP_SECTIONS,
|
||||
aiSetupEntries,
|
||||
aiSetupProviders
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
|
||||
const SECRET =
|
||||
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
|
||||
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
|
||||
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
|
||||
|
||||
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
|
||||
const optional = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
const idField = z.coerce.number().int().positive();
|
||||
|
||||
const providerInput = z.object({
|
||||
name: trimmed(60),
|
||||
products: optional(120),
|
||||
color: z.enum(AI_SETUP_COLORS).catch('zinc')
|
||||
});
|
||||
const entryInput = z
|
||||
.object({
|
||||
section: z.enum(AI_SETUP_SECTIONS),
|
||||
name: trimmed(120),
|
||||
note: optional(400),
|
||||
scope: z
|
||||
.enum(['', ...AI_SETUP_SCOPES])
|
||||
.nullish()
|
||||
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
|
||||
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
|
||||
body: optional(20000)
|
||||
})
|
||||
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
|
||||
message: SECRET_MSG
|
||||
});
|
||||
|
||||
export type SetupEntry = {
|
||||
id: number;
|
||||
section: (typeof AI_SETUP_SECTIONS)[number];
|
||||
name: string;
|
||||
note: string | null;
|
||||
scope: string | null;
|
||||
link: string | null;
|
||||
body: string | null;
|
||||
bodyHtml: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SetupProvider = {
|
||||
id: number;
|
||||
name: string;
|
||||
products: string | null;
|
||||
color: (typeof AI_SETUP_COLORS)[number];
|
||||
updatedAt: string;
|
||||
entries: SetupEntry[];
|
||||
};
|
||||
|
||||
export async function loadSetup(): Promise<SetupProvider[]> {
|
||||
const providers = await db
|
||||
.select()
|
||||
.from(aiSetupProviders)
|
||||
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(aiSetupEntries)
|
||||
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
|
||||
return providers.map((p) => {
|
||||
const own = entries.filter((e) => e.providerId === p.id);
|
||||
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
products: p.products,
|
||||
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
|
||||
? (p.color as SetupProvider['color'])
|
||||
: 'zinc',
|
||||
updatedAt: new Date(latest).toISOString(),
|
||||
entries: own.map((e) => ({
|
||||
id: e.id,
|
||||
section: e.section as SetupEntry['section'],
|
||||
name: e.name,
|
||||
note: e.note,
|
||||
scope: e.scope,
|
||||
link: e.link,
|
||||
body: e.body,
|
||||
bodyHtml: e.body ? renderBody(e.body) : null,
|
||||
updatedAt: e.updatedAt.toISOString()
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type Result = { ok: true } | { ok: false; message: string };
|
||||
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
|
||||
const fields = (form: FormData) =>
|
||||
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||
|
||||
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
|
||||
const touch = (providerId: number) =>
|
||||
db
|
||||
.update(aiSetupProviders)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(aiSetupProviders.id, providerId));
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
|
||||
|
||||
export async function addProvider(form: FormData): Promise<Result> {
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupProviders);
|
||||
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function addEntry(form: FormData): Promise<Result> {
|
||||
const providerId = idField.safeParse(form.get('provider_id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!providerId.success) return { ok: false, message: 'Missing provider' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.providerId, providerId.data));
|
||||
await db
|
||||
.insert(aiSetupEntries)
|
||||
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
|
||||
await touch(providerId.data);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [row] = await db
|
||||
.update(aiSetupEntries)
|
||||
.set(parsed.data)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
const [row] = await db
|
||||
.delete(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -24,6 +24,24 @@ const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
|
||||
});
|
||||
|
||||
describe('bank transaction mapping', () => {
|
||||
it('tags interest and dividend payouts, nothing else', () => {
|
||||
const credit = (remittance: string[], code?: string) =>
|
||||
mapBankTransaction(
|
||||
tx({
|
||||
credit_debit_indicator: 'CRDT',
|
||||
creditor: undefined,
|
||||
debtor: { name: 'Revolut' },
|
||||
remittance_information: remittance,
|
||||
bank_transaction_code: code ? { code } : undefined
|
||||
})
|
||||
)!.category;
|
||||
expect(credit(['Savings vault interest'])).toBe('interest');
|
||||
expect(credit(['Interest PAID EUR Class R IE000AZVL3K0'])).toBe('interest');
|
||||
expect(credit(['Revolut'], 'INTEREST')).toBe('interest');
|
||||
expect(credit(['Dividend Payment: VWRL'])).toBe('dividend');
|
||||
expect(credit(['From JUHEUI KIM & STAN RUNGE'], 'TRANSFER')).toBeNull();
|
||||
expect(mapBankTransaction(tx({ remittance_information: ['Interest'] }))!.category).toBeNull();
|
||||
});
|
||||
it('parses decimal amounts exactly', () => {
|
||||
expect(amountToCents('12.34')).toBe(1234);
|
||||
expect(amountToCents('-0.5')).toBe(-50);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionResponse
|
||||
} from './enable-banking';
|
||||
import { EnableBankingError } from './enable-banking';
|
||||
import { investmentIncomeCategory } from './investment-income';
|
||||
import { findSumMatch } from './reconcile';
|
||||
|
||||
export type MappedTransaction = {
|
||||
@@ -21,6 +22,8 @@ export type MappedTransaction = {
|
||||
counterparty: string | null;
|
||||
amountCents: number;
|
||||
bookingStatus: string;
|
||||
/** 'interest' / 'dividend' for investment income payouts, else null. */
|
||||
category: string | null;
|
||||
raw: BankTransaction;
|
||||
};
|
||||
|
||||
@@ -79,6 +82,17 @@ export function mapBankTransaction(tx: BankTransaction): MappedTransaction | nul
|
||||
counterparty,
|
||||
amountCents,
|
||||
bookingStatus: tx.status ?? 'BOOK',
|
||||
category: investmentIncomeCategory({
|
||||
amountCents,
|
||||
kind: tx.bank_transaction_code?.code,
|
||||
texts: [
|
||||
description,
|
||||
...remittance,
|
||||
tx.bank_transaction_code?.description,
|
||||
tx.bank_transaction_code?.sub_code,
|
||||
tx.note
|
||||
]
|
||||
}),
|
||||
raw: tx
|
||||
};
|
||||
}
|
||||
@@ -435,10 +449,11 @@ export async function syncBankAccounts(
|
||||
for (const { row } of absorbed)
|
||||
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
|
||||
for (const { id, tx } of updates) {
|
||||
// Keep Stan's own description/category; attach the bank identity.
|
||||
// Keep Stan's own description/category (only fill an empty one); attach the bank identity.
|
||||
await q`
|
||||
update money.transactions set external_id = ${tx.externalId},
|
||||
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
||||
category = coalesce(category, ${tx.category}),
|
||||
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
||||
raw = ${q.json(tx.raw as never)}
|
||||
where id = ${id} and external_id is null`;
|
||||
@@ -447,10 +462,10 @@ export async function syncBankAccounts(
|
||||
await q`
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, description, amount_cents, external_id, source,
|
||||
booking_status, counterparty, raw, value_date)
|
||||
booking_status, counterparty, category, raw, value_date)
|
||||
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
|
||||
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
|
||||
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||
${tx.counterparty}, ${tx.category}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||
on conflict (account_id, external_id) do nothing`;
|
||||
}
|
||||
});
|
||||
|
||||
275
src/lib/server/cs-prices.ts
Normal file
275
src/lib/server/cs-prices.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { configRank, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
|
||||
|
||||
// Read side of the CS2 price archive (cs.*): skin search, a skin's configs with their latest
|
||||
// price per source, and one config's daily history. USD streams are shown in EUR using the
|
||||
// ECB rate of that day (cs.fx_rates); `native` keeps the source's own currency.
|
||||
|
||||
export type Stream = { id: number; source: string; currency: string; kind: string; label: string };
|
||||
|
||||
export const kinds = [
|
||||
['weapon', 'Weapons'],
|
||||
['knife', 'Knives'],
|
||||
['gloves', 'Gloves'],
|
||||
['sticker', 'Stickers'],
|
||||
['container', 'Cases'],
|
||||
['agent', 'Agents'],
|
||||
['charm', 'Charms'],
|
||||
['patch', 'Patches'],
|
||||
['music-kit', 'Music kits'],
|
||||
['graffiti', 'Graffiti'],
|
||||
['collectible', 'Pins & coins'],
|
||||
['key', 'Keys']
|
||||
] as const;
|
||||
|
||||
export async function streams(): Promise<Stream[]> {
|
||||
const rows = await db.execute(sql`SELECT id, source, currency, kind,
|
||||
coalesce(label, source || ' ' || replace(kind, '-', ' ')) AS label
|
||||
FROM cs.price_streams WHERE id IS NOT NULL ORDER BY id`);
|
||||
return (rows as unknown as Stream[]).map((row) => ({ ...row, id: Number(row.id) }));
|
||||
}
|
||||
|
||||
export async function overview() {
|
||||
const [row] = (await db.execute(sql`SELECT
|
||||
(SELECT count(*)::int FROM cs.skins) AS skins,
|
||||
(SELECT count(*)::int FROM cs.items) AS configs,
|
||||
(SELECT max(day)::text FROM cs.daily_prices) AS latest_day,
|
||||
(SELECT count(*)::int FROM cs.daily_prices
|
||||
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_rows,
|
||||
(SELECT count(DISTINCT item_id)::int FROM cs.daily_prices
|
||||
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_configs,
|
||||
(SELECT greatest(reltuples::bigint, 0) FROM pg_class
|
||||
WHERE oid = 'cs.daily_prices'::regclass)::bigint AS rows,
|
||||
(SELECT min(day)::text FROM cs.daily_prices) AS earliest_day,
|
||||
(SELECT max(last_fetched_at) FROM cs.price_streams) AS fetched_at`)) as unknown as {
|
||||
skins: number;
|
||||
configs: number;
|
||||
latest_day: string | null;
|
||||
latest_rows: number;
|
||||
latest_configs: number;
|
||||
rows: string;
|
||||
earliest_day: string | null;
|
||||
fetched_at: Date | null;
|
||||
}[];
|
||||
return row;
|
||||
}
|
||||
|
||||
export type SkinHit = {
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
image: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
configs: number;
|
||||
from_cents: number | null;
|
||||
listings: number | null;
|
||||
};
|
||||
|
||||
/** Skins matching every word of `q`, with today's cheapest EUR listing across their configs. */
|
||||
export async function searchSkins(q: string, kind: string, sort: string, limit = 60, offset = 0) {
|
||||
const words = q
|
||||
.trim()
|
||||
.slice(0, 100)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.map(
|
||||
(word) => `%${word.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
|
||||
);
|
||||
const filter = words.length
|
||||
? sql.join(
|
||||
words.map((word) => sql`s.name ILIKE ${word} ESCAPE '\\'`),
|
||||
sql` AND `
|
||||
)
|
||||
: sql`true`;
|
||||
const kindFilter =
|
||||
kind && kinds.some(([value]) => value === kind) ? sql`s.kind = ${kind}` : sql`true`;
|
||||
const order =
|
||||
sort === 'price'
|
||||
? sql`p.from_cents DESC NULLS LAST, s.name`
|
||||
: sort === 'name'
|
||||
? sql`s.name`
|
||||
: sql`p.listings DESC NULLS LAST, s.name`;
|
||||
const rows = await db.execute(sql`
|
||||
WITH listing AS (
|
||||
SELECT id FROM cs.price_streams WHERE source = 'skinport' AND kind = 'lowest-listing'
|
||||
), latest AS (
|
||||
SELECT max(day) AS day FROM cs.daily_prices WHERE stream_id = (SELECT id FROM listing)
|
||||
), p AS (
|
||||
SELECT i.skin_id, min(dp.price_cents)::int AS from_cents, sum(dp.volume)::int AS listings
|
||||
FROM cs.daily_prices dp JOIN cs.items i ON i.id = dp.item_id
|
||||
WHERE dp.stream_id = (SELECT id FROM listing) AND dp.day = (SELECT day FROM latest)
|
||||
GROUP BY i.skin_id
|
||||
)
|
||||
SELECT s.slug, s.name, s.kind, s.image, s.rarity, s.rarity_color, p.from_cents, p.listings,
|
||||
(SELECT count(*)::int FROM cs.items i WHERE i.skin_id = s.id) AS configs
|
||||
FROM cs.skins s LEFT JOIN p ON p.skin_id = s.id
|
||||
WHERE ${filter} AND ${kindFilter}
|
||||
ORDER BY ${words.length ? sql`(s.name ILIKE ${words[0].slice(1)} ESCAPE '\\') DESC,` : sql``} ${order}
|
||||
LIMIT ${Math.min(120, Math.max(1, limit))} OFFSET ${Math.min(50_000, Math.max(0, offset))}`);
|
||||
return rows as unknown as SkinHit[];
|
||||
}
|
||||
|
||||
export type ConfigRow = {
|
||||
id: number;
|
||||
market_hash_name: string;
|
||||
phase: string | null;
|
||||
wear: string | null;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
icon_url: string | null;
|
||||
prices: {
|
||||
stream: number;
|
||||
cents: number;
|
||||
eur_cents: number;
|
||||
volume: number | null;
|
||||
day: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Variant = 'normal' | 'stattrak' | 'souvenir';
|
||||
export const variantOf = (row: { stattrak: boolean; souvenir: boolean }): Variant =>
|
||||
row.souvenir ? 'souvenir' : row.stattrak ? 'stattrak' : 'normal';
|
||||
const shortToWear = Object.fromEntries(Object.entries(wearShort).map(([wear, s]) => [s, wear]));
|
||||
|
||||
export async function loadSkin(slug: string) {
|
||||
const [skin] = (await db.execute(sql`SELECT id, slug, name, kind, weapon, pattern, category,
|
||||
rarity, rarity_color, image, min_float, max_float, collections
|
||||
FROM cs.skins WHERE slug = ${slug}`)) as unknown as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
weapon: string | null;
|
||||
pattern: string | null;
|
||||
category: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
image: string | null;
|
||||
min_float: number | null;
|
||||
max_float: number | null;
|
||||
collections: string[] | null;
|
||||
}[];
|
||||
if (!skin) return null;
|
||||
// Latest price per config and stream within the last 10 days, converted to EUR.
|
||||
const configs = (await db.execute(sql`
|
||||
WITH fx AS (SELECT usd_eur FROM cs.fx_rates ORDER BY day DESC LIMIT 1)
|
||||
SELECT i.id, i.market_hash_name, i.phase, i.wear, i.stattrak, i.souvenir, i.icon_url,
|
||||
coalesce((SELECT json_agg(json_build_object('stream', l.stream_id, 'cents', l.price_cents,
|
||||
'eur_cents', CASE WHEN st.currency = 'USD'
|
||||
THEN round(l.price_cents * coalesce((SELECT usd_eur FROM fx), 0.92))::int
|
||||
ELSE l.price_cents END,
|
||||
'volume', l.volume, 'day', l.day::text) ORDER BY l.stream_id)
|
||||
FROM (SELECT DISTINCT ON (dp.stream_id) dp.stream_id, dp.price_cents, dp.volume, dp.day
|
||||
FROM cs.daily_prices dp WHERE dp.item_id = i.id AND dp.day >= current_date - 10
|
||||
ORDER BY dp.stream_id, dp.day DESC) l
|
||||
JOIN cs.price_streams st ON st.id = l.stream_id), '[]'::json) AS prices
|
||||
FROM cs.items i WHERE i.skin_id = ${skin.id}`)) as unknown as ConfigRow[];
|
||||
configs.sort((a, b) => configRank(a) - configRank(b));
|
||||
return { skin, configs };
|
||||
}
|
||||
|
||||
/** Pick the config from ?v=&wear=&phase=, falling back to the cheapest priced normal config. */
|
||||
export function pickConfig(configs: ConfigRow[], params: URLSearchParams) {
|
||||
const variant = params.get('v') as Variant | null;
|
||||
const wear = shortToWear[params.get('wear') ?? ''] ?? null;
|
||||
const phase = params.get('phase') || null;
|
||||
// Default to the most traded normal config (most listings/sales across sources).
|
||||
const liquidity = (row: ConfigRow) => row.prices.reduce((sum, p) => sum + (p.volume ?? 0), 0);
|
||||
const fallback = () =>
|
||||
configs
|
||||
.filter((row) => row.prices.length && !row.phase && variantOf(row) === 'normal')
|
||||
.sort((a, b) => liquidity(b) - liquidity(a))[0] ??
|
||||
configs.find((row) => row.prices.length) ??
|
||||
configs[0] ??
|
||||
null;
|
||||
if (!variant && !params.has('wear') && !params.has('phase')) return fallback();
|
||||
const exact = configs.find(
|
||||
(row) =>
|
||||
(!variant || variantOf(row) === variant) &&
|
||||
(params.has('wear') ? row.wear === wear : true) &&
|
||||
(params.has('phase') ? row.phase === phase : !row.phase)
|
||||
);
|
||||
if (exact) return exact;
|
||||
const near = configs.find(
|
||||
(row) => (!variant || variantOf(row) === variant) && (!wear || row.wear === wear)
|
||||
);
|
||||
return near ?? fallback();
|
||||
}
|
||||
|
||||
export type SeriesPoint = [string, number];
|
||||
|
||||
/** Daily EUR series per stream for one config. Doppler phases also get the phase-blind
|
||||
* listing's streams they lack (e.g. Steam has no phases), labelled as "any phase". */
|
||||
export async function loadSeries(config: ConfigRow, from: string | null) {
|
||||
const ids = [config.id];
|
||||
if (config.phase) {
|
||||
const [base] = (await db.execute(sql`SELECT id FROM cs.items
|
||||
WHERE market_hash_name = ${config.market_hash_name} AND phase IS NULL`)) as unknown as {
|
||||
id: number;
|
||||
}[];
|
||||
if (base) ids.push(base.id);
|
||||
}
|
||||
const rows = (await db.execute(sql`
|
||||
SELECT dp.item_id, dp.stream_id, dp.day::text AS day, dp.volume,
|
||||
CASE WHEN st.currency = 'USD' THEN round(dp.price_cents * coalesce(
|
||||
(SELECT usd_eur FROM cs.fx_rates f WHERE f.day <= dp.day ORDER BY f.day DESC LIMIT 1),
|
||||
(SELECT usd_eur FROM cs.fx_rates ORDER BY day LIMIT 1), 0.92))::int
|
||||
ELSE dp.price_cents END AS eur_cents
|
||||
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||
WHERE dp.item_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`,`
|
||||
)}) ${from ? sql`AND dp.day >= ${from}::date` : sql``}
|
||||
ORDER BY dp.day`)) as unknown as {
|
||||
item_id: number;
|
||||
stream_id: number;
|
||||
day: string;
|
||||
volume: number | null;
|
||||
eur_cents: number;
|
||||
}[];
|
||||
const own = new Set(rows.filter((r) => r.item_id === config.id).map((r) => r.stream_id));
|
||||
const series = new Map<string, { stream: number; anyPhase: boolean; points: SeriesPoint[] }>();
|
||||
for (const row of rows) {
|
||||
const anyPhase = row.item_id !== config.id;
|
||||
if (anyPhase && own.has(row.stream_id)) continue;
|
||||
const key = `${row.stream_id}:${anyPhase}`;
|
||||
const entry = series.get(key) ?? { stream: Number(row.stream_id), anyPhase, points: [] };
|
||||
entry.points.push([row.day, row.eur_cents]);
|
||||
series.set(key, entry);
|
||||
}
|
||||
const windows = (await db.execute(sql`SELECT item_id, source, currency, span, median_cents,
|
||||
avg_cents, min_cents, max_cents, volume FROM cs.price_windows
|
||||
WHERE item_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`,`
|
||||
)})`)) as unknown as {
|
||||
item_id: number;
|
||||
source: string;
|
||||
currency: string;
|
||||
span: string;
|
||||
median_cents: number | null;
|
||||
avg_cents: number | null;
|
||||
min_cents: number | null;
|
||||
max_cents: number | null;
|
||||
volume: number | null;
|
||||
}[];
|
||||
const [fx] = (await db.execute(
|
||||
sql`SELECT usd_eur::float AS rate, day::text FROM cs.fx_rates ORDER BY day DESC LIMIT 1`
|
||||
)) as unknown as { rate: number; day: string }[];
|
||||
return {
|
||||
series: [...series.values()],
|
||||
windows: windows
|
||||
.filter(
|
||||
(w) =>
|
||||
w.item_id === config.id ||
|
||||
!windows.some((o) => o.item_id === config.id && o.source === w.source)
|
||||
)
|
||||
.map((w) => ({ ...w, anyPhase: w.item_id !== config.id })),
|
||||
fx: fx ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export { wearOrder, wearShort };
|
||||
@@ -1,5 +1,13 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
check,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { aiSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
|
||||
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
|
||||
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
|
||||
* like a secret, and nothing here is ever a key value.
|
||||
*/
|
||||
export const AI_SETUP_SECTIONS = [
|
||||
'connections',
|
||||
'skills',
|
||||
'instructions',
|
||||
'plugins',
|
||||
'routines',
|
||||
'agents',
|
||||
'plan',
|
||||
'notes'
|
||||
] as const;
|
||||
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
|
||||
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
|
||||
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
|
||||
export const AI_SETUP_COLORS = [
|
||||
'orange',
|
||||
'green',
|
||||
'blue',
|
||||
'violet',
|
||||
'pink',
|
||||
'sky',
|
||||
'amber',
|
||||
'zinc'
|
||||
] as const;
|
||||
|
||||
export const aiSetupProviders = aiSchema.table(
|
||||
'provider_setup_providers',
|
||||
{
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
/** The products this card covers, e.g. "claude.ai + Claude Code". */
|
||||
products: text('products'),
|
||||
color: text('color').notNull().default('zinc'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('provider_setup_providers_name_key').on(t.name),
|
||||
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||
check(
|
||||
'provider_setup_providers_color_check',
|
||||
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiSetupEntries = aiSchema.table(
|
||||
'provider_setup_entries',
|
||||
{
|
||||
id: id(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
section: text('section').notNull(),
|
||||
name: text('name').notNull(),
|
||||
note: text('note'),
|
||||
scope: text('scope'),
|
||||
link: text('link'),
|
||||
/** Free markdown text (custom instructions, longer notes). */
|
||||
body: text('body'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
|
||||
check(
|
||||
'provider_setup_entries_section_check',
|
||||
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
|
||||
),
|
||||
check(
|
||||
'provider_setup_entries_scope_check',
|
||||
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
|
||||
),
|
||||
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -5,11 +5,45 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
bigint
|
||||
bigint,
|
||||
boolean,
|
||||
date,
|
||||
real,
|
||||
smallint,
|
||||
numeric
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { csSchema } from './schemas';
|
||||
import { id } from './helpers';
|
||||
|
||||
// One finish or item, grouping all of its market configs (wear, StatTrak, Souvenir, phase).
|
||||
// Ids come from ByMykel/CSGO-API (skin_id / item id); items only seen in price feeds get `mkt-<slug>`.
|
||||
export const csSkins = csSchema.table(
|
||||
'skins',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
weapon: text('weapon'),
|
||||
pattern: text('pattern'),
|
||||
category: text('category'),
|
||||
rarity: text('rarity'),
|
||||
rarityColor: text('rarity_color'),
|
||||
image: text('image'),
|
||||
minFloat: real('min_float'),
|
||||
maxFloat: real('max_float'),
|
||||
collections: text('collections').array(),
|
||||
source: text('source').notNull().default('bymykel'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('skins_slug_idx').on(table.slug),
|
||||
index('skins_name_idx').on(table.name),
|
||||
index('skins_kind_idx').on(table.kind)
|
||||
]
|
||||
);
|
||||
|
||||
// A Steam market hash name identifies a listing, including wear and special variants.
|
||||
export const csMarketItems = csSchema.table(
|
||||
'items',
|
||||
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
|
||||
name: text('name').notNull(),
|
||||
iconUrl: text('icon_url'),
|
||||
itemType: text('item_type'),
|
||||
// Catalog link and config facets. Doppler phases share a market hash name, so a phase
|
||||
// row is its own config next to the phase-less listing.
|
||||
skinId: text('skin_id').references(() => csSkins.id, { onDelete: 'set null' }),
|
||||
phase: text('phase'),
|
||||
wear: text('wear'),
|
||||
stattrak: boolean('stattrak').notNull().default(false),
|
||||
souvenir: boolean('souvenir').notNull().default(false),
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('items_hash_idx').on(table.marketHashName),
|
||||
index('items_name_idx').on(table.name)
|
||||
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
|
||||
index('items_name_idx').on(table.name),
|
||||
index('items_skin_idx').on(table.skinId)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -82,9 +124,82 @@ export const csPriceStreams = csSchema.table(
|
||||
{
|
||||
source: text('source').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
kind: text('kind').notNull()
|
||||
kind: text('kind').notNull(),
|
||||
id: smallint('id').generatedByDefaultAsIdentity(),
|
||||
label: text('label'),
|
||||
lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }),
|
||||
lastRows: integer('last_rows'),
|
||||
lastError: text('last_error')
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
|
||||
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] }),
|
||||
uniqueIndex('price_streams_id_idx').on(table.id)
|
||||
]
|
||||
);
|
||||
|
||||
// One price per config per stream per UTC day (idempotent upsert). Prices stay in the stream's
|
||||
// own currency; fx_rates converts USD streams to EUR for display.
|
||||
export const csDailyPrices = csSchema.table(
|
||||
'daily_prices',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
streamId: smallint('stream_id').notNull(),
|
||||
day: date('day').notNull(),
|
||||
priceCents: integer('price_cents').notNull(),
|
||||
volume: integer('volume')
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'daily_prices_pkey', columns: [table.itemId, table.streamId, table.day] }),
|
||||
index('daily_prices_day_idx').on(table.day, table.streamId)
|
||||
]
|
||||
);
|
||||
|
||||
// Rolling sale aggregates (24h/7d/30d/90d) as reported by the source, latest snapshot only.
|
||||
export const csPriceWindows = csSchema.table(
|
||||
'price_windows',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
source: text('source').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
span: text('span').notNull(),
|
||||
medianCents: integer('median_cents'),
|
||||
avgCents: integer('avg_cents'),
|
||||
minCents: integer('min_cents'),
|
||||
maxCents: integer('max_cents'),
|
||||
volume: integer('volume'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
name: 'price_windows_pkey',
|
||||
columns: [table.itemId, table.source, table.span]
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
export const csFxRates = csSchema.table('fx_rates', {
|
||||
day: date('day').primaryKey(),
|
||||
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
|
||||
});
|
||||
|
||||
// Backfill bookkeeping (scripts/cs-history.ts): which config was fetched from which history
|
||||
// source, so the slow, throttled backfill can resume. History rows themselves live in
|
||||
// daily_prices under their own streams (kind ending in `-history`).
|
||||
export const csHistoryFetches = csSchema.table(
|
||||
'history_fetches',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
source: text('source').notNull(),
|
||||
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
points: integer('points').notNull().default(0),
|
||||
firstDay: date('first_day'),
|
||||
error: text('error')
|
||||
},
|
||||
(table) => [primaryKey({ name: 'history_fetches_pkey', columns: [table.itemId, table.source] })]
|
||||
);
|
||||
|
||||
@@ -33,3 +33,5 @@ export * from './movies';
|
||||
export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
|
||||
112
src/lib/server/db/schema/math.ts
Normal file
112
src/lib/server/db/schema/math.ts
Normal 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')`)
|
||||
]
|
||||
);
|
||||
118
src/lib/server/db/schema/plant.ts
Normal file
118
src/lib/server/db/schema/plant.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex,
|
||||
type AnyPgColumn
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { plantSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): what the Cargill Bioindustrial plant
|
||||
// does. One generic `entries` table (departments, products, process steps, equipment, glossary,
|
||||
// questions, notes, roles) plus typed `links` between them. Bots apply JSON with
|
||||
// `select plant.apply_entries('{...}'::jsonb)` (shape documented in the migration and in
|
||||
// docs/plant-tracker.md). Kinds, confidences and relations are kept in sync with #lib/plant.
|
||||
|
||||
export const plantEntries = plantSchema.table(
|
||||
'entries',
|
||||
{
|
||||
id: id(),
|
||||
/** process_step | product | department | equipment | glossary | question | note | person_role */
|
||||
kind: text('kind').notNull(),
|
||||
/** Unique per kind; used in URLs and by apply_entries. */
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
/** Markdown. */
|
||||
body: text('body'),
|
||||
parentId: integer('parent_id').references((): AnyPgColumn => plantEntries.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
tags: text('tags')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
/** Where it came from, e.g. "page 2026-10-26", "Walter White", "Stan". */
|
||||
source: text('source'),
|
||||
/** confirmed (checked at work) | assumed (from the guide) | open (unknown). */
|
||||
confidence: text('confidence').notNull().default('assumed'),
|
||||
/** Questions: when it was answered (null = still open). */
|
||||
answeredAt: timestamptz('answered_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('entries_kind_slug_key').on(t.kind, t.slug),
|
||||
index('entries_parent_idx').on(t.parentId),
|
||||
check(
|
||||
'entries_kind_check',
|
||||
sql`${t.kind} IN ('process_step','product','department','equipment','glossary','question','note','person_role')`
|
||||
),
|
||||
check('entries_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
check('entries_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`)
|
||||
]
|
||||
);
|
||||
|
||||
export const plantLinks = plantSchema.table(
|
||||
'links',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
toId: integer('to_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** feeds_into | made_in | uses | part_of | related */
|
||||
relation: text('relation').notNull(),
|
||||
/** Short edge label, e.g. "fatty acids". */
|
||||
label: text('label'),
|
||||
/** Drawn dashed in the flow (optional path or a loop back); doesn't push layers down. */
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
/** confirmed | assumed | open; not-confirmed links are drawn dashed with a "?". */
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
check('links_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
uniqueIndex('links_from_to_relation_key').on(t.fromId, t.toId, t.relation),
|
||||
index('links_to_idx').on(t.toId),
|
||||
check(
|
||||
'links_relation_check',
|
||||
sql`${t.relation} IN ('feeds_into','made_in','uses','part_of','related')`
|
||||
),
|
||||
check('links_not_self_check', sql`${t.fromId} <> ${t.toId}`)
|
||||
]
|
||||
);
|
||||
|
||||
/** Parents and links whose target isn't in the tracker yet. apply_entries retries them on every
|
||||
* run, so a later batch that adds the target resolves them; the detail page lists them as pending. */
|
||||
export const plantPending = plantSchema.table(
|
||||
'pending',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** "parent" or a link relation. */
|
||||
field: text('field').notNull(),
|
||||
/** The reference as given ("slug" or "kind:slug"). */
|
||||
toRef: text('to_ref').notNull(),
|
||||
label: text('label'),
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('pending_from_field_ref_key').on(t.fromId, t.field, t.toRef),
|
||||
check(
|
||||
'pending_field_check',
|
||||
sql`${t.field} IN ('parent','feeds_into','made_in','uses','part_of','related')`
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -29,3 +29,5 @@ 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');
|
||||
export const plantSchema = pgSchema('plant');
|
||||
|
||||
@@ -25,6 +25,7 @@ const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as co
|
||||
const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
|
||||
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||
const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
|
||||
|
||||
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
|
||||
|
||||
@@ -36,6 +37,11 @@ export const worldtodoRequestStatus = worldtodoSchema.enum(
|
||||
WORLDTODO_REQUEST_STATUSES
|
||||
);
|
||||
|
||||
export const worldtodoLocationPrecision = worldtodoSchema.enum(
|
||||
'location_precision',
|
||||
WORLDTODO_LOCATION_PRECISIONS
|
||||
);
|
||||
|
||||
export const worldtodoCategories = worldtodoSchema.table('categories', {
|
||||
slug: text('slug').primaryKey(),
|
||||
label: text('label').notNull(),
|
||||
@@ -61,6 +67,13 @@ export const worldtodoTasks = worldtodoSchema.table(
|
||||
countryCode: text('country_code'),
|
||||
lat: doublePrecision('lat'),
|
||||
lon: doublePrecision('lon'),
|
||||
/**
|
||||
* How the map treats lat/lon: street and city tasks are exact pins, region and country
|
||||
* pins sit at a rough centre, planet tasks (or no coordinates) get no pin. Set from scale
|
||||
* and lat/lon by the tasks_location_precision trigger (hand-written in the migration), so
|
||||
* it never drifts from the data.
|
||||
*/
|
||||
locationPrecision: worldtodoLocationPrecision('location_precision'),
|
||||
status: worldtodoStatus('status').notNull().default('open'),
|
||||
sourceUrls: text('source_urls').array().notNull(),
|
||||
/** Date of the main source as given ('2026-09', '2026-10-21'). */
|
||||
|
||||
@@ -48,7 +48,11 @@ export type BankTransaction = {
|
||||
creditor_account?: { iban?: string | null } | null;
|
||||
debtor_account?: { iban?: string | null } | null;
|
||||
remittance_information?: string[] | null;
|
||||
bank_transaction_code?: { description?: string | null } | null;
|
||||
bank_transaction_code?: {
|
||||
code?: string | null;
|
||||
sub_code?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
merchant_category_code?: string | null;
|
||||
note?: string | null;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -23,6 +23,10 @@ export type GoalMetricDetail = {
|
||||
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
|
||||
|
||||
export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m';
|
||||
/** money.transactions.category values that count as investment income (agreed with MoneyMan).
|
||||
* Dividends, interest (incl. Revolut savings interest) and distributions are recorded as one
|
||||
* of these; deposits, transfers, investment sales and unrealised gains are NOT. */
|
||||
export const INVESTMENT_INCOME_CATEGORIES = ['dividend', 'interest'];
|
||||
const TZ = 'Europe/Amsterdam';
|
||||
|
||||
type SourceRow = {
|
||||
@@ -54,6 +58,18 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
||||
select value, detail, computed_at, error from tasks.metric_sources
|
||||
where key = ${SIDE_INCOME_SOURCE}`;
|
||||
|
||||
// Investment income: trailing 12 months of money.transactions with amount_cents > 0 and
|
||||
// category in INVESTMENT_INCOME_CATEGORIES, on any account except Joint (shared), ÷ 12.
|
||||
const [inv] = await sql<{ cents: string; n: number }[]>`
|
||||
select coalesce(sum(t.amount_cents), 0) as cents, count(*)::int as n
|
||||
from money.transactions t
|
||||
join money.accounts a on a.id = t.account_id
|
||||
where t.amount_cents > 0
|
||||
and t.category = any(${INVESTMENT_INCOME_CATEGORIES})
|
||||
and a.scope <> 'joint'
|
||||
and t.date > ((now() at time zone ${TZ})::date - interval '12 months')
|
||||
and t.date <= (now() at time zone ${TZ})::date`;
|
||||
|
||||
const parts: GoalMetricPart[] = rows.map((r) => ({
|
||||
key: `recurring-${r.id}`,
|
||||
label: r.name,
|
||||
@@ -71,6 +87,12 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
||||
: 'not fetched yet',
|
||||
asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null
|
||||
});
|
||||
parts.push({
|
||||
key: 'investment-income',
|
||||
label: 'Investment income',
|
||||
value: round2(Number(inv?.cents ?? 0) / 100 / 12),
|
||||
note: `${inv?.n ?? 0} dividend/interest payments, last 12 months ÷ 12`
|
||||
});
|
||||
return {
|
||||
value: round2(parts.reduce((s, p) => s + p.value, 0)),
|
||||
detail: {
|
||||
@@ -87,7 +109,7 @@ export const GOAL_METRICS: Record<
|
||||
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
|
||||
> = {
|
||||
'own-net-income-monthly': {
|
||||
label: 'Own net income / month (salary + side income)',
|
||||
label: 'Own net income / month (salary + side + investment income)',
|
||||
compute: ownNetIncomeMonthly
|
||||
}
|
||||
};
|
||||
|
||||
41
src/lib/server/investment-income.test.ts
Normal file
41
src/lib/server/investment-income.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { investmentIncomeCategory as cat } from './investment-income';
|
||||
|
||||
const pos = (texts: string[], kind?: string) => cat({ amountCents: 123, kind, texts });
|
||||
|
||||
describe('investment income tagging', () => {
|
||||
it('tags interest payouts from real and Revolut wording', () => {
|
||||
for (const text of [
|
||||
'Interest',
|
||||
'Knab: Rente',
|
||||
'Interest PAID EUR Class R IE000AZVL3K0',
|
||||
'Savings Interest',
|
||||
'Savings vault interest',
|
||||
'Gross interest',
|
||||
'Net interest',
|
||||
'Interest earned',
|
||||
'Rentebijschrijving'
|
||||
])
|
||||
expect(pos([text]), text).toBe('interest');
|
||||
expect(pos(['Revolut'], 'Interest')).toBe('interest');
|
||||
expect(pos(['Revolut', 'Interest earned on your Savings'], 'TRANSFER')).toBe('interest');
|
||||
});
|
||||
it('tags dividend payouts', () => {
|
||||
expect(pos(['Dividend Payment: GOOGL'])).toBe('dividend');
|
||||
expect(pos(['VWRL dividend'])).toBe('dividend');
|
||||
expect(pos(['Dividenduitkering'])).toBe('dividend');
|
||||
expect(pos(['Broker'], 'DIVIDEND')).toBe('dividend');
|
||||
});
|
||||
it('ignores costs, reinvestments, card rows and look-alikes', () => {
|
||||
expect(cat({ amountCents: -1043, texts: ['Interest Reinvested Class R EUR'] })).toBeNull();
|
||||
expect(cat({ amountCents: -2, texts: ['Service Fee Charged EUR Class'] })).toBeNull();
|
||||
expect(cat({ amountCents: -500, texts: ['Dividend tax'] })).toBeNull();
|
||||
expect(cat({ amountCents: 0, texts: ['Interest'] })).toBeNull();
|
||||
expect(pos(['Interest Cafe'], 'Card Refund')).toBeNull();
|
||||
expect(pos(['Interest Cafe Utrecht'])).toBeNull();
|
||||
expect(pos(['From Juhee'])).toBeNull();
|
||||
expect(pos(['Tikkie: Rente lening terug'])).toBeNull();
|
||||
expect(pos(['Rentenaar BV'])).toBeNull();
|
||||
expect(pos(['Salaris Cargill'])).toBeNull();
|
||||
});
|
||||
});
|
||||
49
src/lib/server/investment-income.ts
Normal file
49
src/lib/server/investment-income.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tags investment income on import so the €10k/month goal bar (goal-metrics/core.ts,
|
||||
// INVESTMENT_INCOME_CATEGORIES) can find it: positive interest payouts -> 'interest',
|
||||
// positive dividend payouts -> 'dividend'. Pure, shared by the bank sync and statement imports.
|
||||
|
||||
export type IncomeCategory = 'interest' | 'dividend';
|
||||
|
||||
/** Revolut "Type" / bank transaction codes that are never income payouts. */
|
||||
const NOT_INCOME_KIND = /^(card[ _-]?(payment|refund)|exchange|atm|fee|charge)$/i;
|
||||
|
||||
const DIVIDEND = /\bdividend(?:s|en|uitkering)?\b/i;
|
||||
|
||||
/**
|
||||
* Interest wording seen in real rows and Revolut exports:
|
||||
* "Interest" (ABN), "Knab: Rente", Revolut "Interest PAID EUR Class R IE000…",
|
||||
* "Savings Interest", "Savings vault interest", "Gross/Net interest", "Rentebijschrijving".
|
||||
* Payout-only: "Interest Reinvested" / "Service Fee Charged" are negative and so never match.
|
||||
*/
|
||||
const INTEREST = [
|
||||
// Whole description is (a bank prefix plus) the word interest/rente, optionally qualified.
|
||||
/^(?:[\p{L}0-9 .&'-]{1,30}:\s*)?(?:(?:gross|net|savings|vault|credit)\s+)?(?:interest|rente|creditrente)(?:\s+(?:paid|earned|payout|payment|credited|bijschrijving|uitbetaling)\b.*)?$/iu,
|
||||
/^interest\s+paid\b/i,
|
||||
/\b(?:savings|vault|pocket|spaar)\s*(?:account\s+)?interest\b/i,
|
||||
/\binterest\s+(?:earned|payout|credited)\b/i,
|
||||
/\brente(?:bijschrijving|uitkering|vergoeding)\b/i
|
||||
];
|
||||
|
||||
export type IncomeInput = {
|
||||
amountCents: number;
|
||||
/** Revolut statement "Type" or the bank's transaction code. */
|
||||
kind?: string | null;
|
||||
/** Description, remittance lines, bank code description, note… */
|
||||
texts: (string | null | undefined)[];
|
||||
};
|
||||
|
||||
export function investmentIncomeCategory({
|
||||
amountCents,
|
||||
kind,
|
||||
texts
|
||||
}: IncomeInput): IncomeCategory | null {
|
||||
if (!(amountCents > 0)) return null;
|
||||
const k = kind?.trim() ?? '';
|
||||
if (k && NOT_INCOME_KIND.test(k)) return null;
|
||||
if (DIVIDEND.test(k)) return 'dividend';
|
||||
if (/\binterest\b/i.test(k)) return 'interest';
|
||||
const lines = texts.map((t) => t?.replace(/\s+/g, ' ').trim()).filter((t): t is string => !!t);
|
||||
if (lines.some((t) => DIVIDEND.test(t))) return 'dividend';
|
||||
if (lines.some((t) => INTEREST.some((re) => re.test(t)))) return 'interest';
|
||||
return null;
|
||||
}
|
||||
134
src/lib/server/math.ts
Normal file
134
src/lib/server/math.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,19 @@ 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('gates the whole cargill plant tracker, reads and writes', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/cargill')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargill/__data.json')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargill/process-step/cleaning')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargillx')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/cargill')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
|
||||
});
|
||||
it('only lets the owner write in owner areas', () => {
|
||||
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/books')).toBe(true);
|
||||
|
||||
@@ -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,20 @@ 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',
|
||||
// Cargill plant tracker: what the factory I work at does. Mine only.
|
||||
'/trackers/cargill',
|
||||
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
|
||||
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -66,7 +72,9 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/pc',
|
||||
'/trackers/notes',
|
||||
'/trackers/ai',
|
||||
'/trackers/movies'
|
||||
'/trackers/movies',
|
||||
'/trackers/math',
|
||||
'/trackers/cargill'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
|
||||
27
src/lib/server/plant-actions.ts
Normal file
27
src/lib/server/plant-actions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Form actions shared by the /trackers/cargill overview and entry pages. Actions don't run the
|
||||
// load, so each one checks the owner again (hooks.server.ts also 404s the whole path).
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { addLink, answerQuestion, deleteEntry, removeLink, saveEntry } from '#lib/server/plant.js';
|
||||
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
type Handler = (
|
||||
form: FormData
|
||||
) => Promise<{ ok: true; href?: string } | { ok: false; message: string }>;
|
||||
|
||||
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const r = await fn(form);
|
||||
if (!r.ok) return fail(400, { message: r.message });
|
||||
// Deleting from an entry's own page goes back to the overview.
|
||||
if (form.get('redirect') === '1' && r.href) redirect(303, r.href);
|
||||
return { ok: true as const, href: r.href };
|
||||
};
|
||||
|
||||
export const plantActions = {
|
||||
save: action(saveEntry),
|
||||
delete: action(deleteEntry),
|
||||
answer: action(answerQuestion),
|
||||
link: action(addLink),
|
||||
unlink: action(removeLink)
|
||||
};
|
||||
232
src/lib/server/plant.ts
Normal file
232
src/lib/server/plant.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): loads entries and links, renders
|
||||
// markdown bodies, and applies owner edits. Every write goes through plant.apply_entries (the
|
||||
// same JSON format bots use, see the plant migration) or a plain delete, so the site and bots
|
||||
// share one code path.
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import {
|
||||
CONFIDENCES,
|
||||
PLANT_KINDS,
|
||||
RELATIONS,
|
||||
slugify,
|
||||
type Confidence,
|
||||
type PlantKind,
|
||||
type Relation
|
||||
} from '#lib/plant.js';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown bodies. Raw HTML is shown as text, never rendered; only http(s)/mailto/relative links.
|
||||
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)}"` : '';
|
||||
const ext = /^(https?:|mailto:)/i.test(href)
|
||||
? ' rel="noopener noreferrer" target="_blank"'
|
||||
: '';
|
||||
return `<a href="${escapeHtml(href)}"${t}${ext}>${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
export const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
export type PlantEntry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: Confidence;
|
||||
answeredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type PlantLink = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: Relation;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
/** Not-confirmed links show dashed with a "?". */
|
||||
confidence: Confidence;
|
||||
};
|
||||
|
||||
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
|
||||
|
||||
export async function loadPlant() {
|
||||
const [entries, links] = await Promise.all([
|
||||
client`SELECT id, kind, slug, title, summary, body, parent_id, tags, sort_order, source,
|
||||
confidence, answered_at, created_at, updated_at
|
||||
FROM plant.entries ORDER BY kind, sort_order, lower(title)`,
|
||||
client`SELECT id, from_id, to_id, relation, label, dashed, confidence
|
||||
FROM plant.links ORDER BY id`
|
||||
]);
|
||||
return {
|
||||
entries: entries.map((e): PlantEntry => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
slug: e.slug,
|
||||
title: e.title,
|
||||
summary: e.summary,
|
||||
body: e.body,
|
||||
parentId: e.parent_id,
|
||||
tags: e.tags ?? [],
|
||||
sortOrder: e.sort_order,
|
||||
source: e.source,
|
||||
confidence: e.confidence,
|
||||
answeredAt: iso(e.answered_at),
|
||||
createdAt: iso(e.created_at)!,
|
||||
updatedAt: iso(e.updated_at)!
|
||||
})),
|
||||
links: links.map((l): PlantLink => ({
|
||||
id: l.id,
|
||||
fromId: l.from_id,
|
||||
toId: l.to_id,
|
||||
relation: l.relation,
|
||||
label: l.label,
|
||||
dashed: l.dashed,
|
||||
confidence: l.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
type Result = { ok: true; href?: string } | { ok: false; message: string };
|
||||
|
||||
const text = (max: number) => z.string().trim().max(max);
|
||||
const entryInput = z.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
kind: z.enum(PLANT_KINDS),
|
||||
title: text(200).min(1, 'A title is required'),
|
||||
slug: text(80).optional(),
|
||||
summary: text(500).optional(),
|
||||
body: z.string().max(50_000).optional(),
|
||||
tags: text(400).optional(),
|
||||
parent_id: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
|
||||
sort_order: z.union([z.literal(''), z.coerce.number().int().min(-100000).max(100000)]).optional(),
|
||||
source: text(200).optional(),
|
||||
confidence: z.enum(CONFIDENCES)
|
||||
});
|
||||
|
||||
const formObject = (form: FormData) => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
|
||||
return o;
|
||||
};
|
||||
const dbMessage = (e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('entries_kind_slug_key')) return 'That slug is already used for this kind';
|
||||
if (msg.startsWith('plant')) return msg.replace(/^plant(\.apply_entries)?: /, '');
|
||||
throw e;
|
||||
};
|
||||
|
||||
/** Create (no id) or update (id) one entry from the owner form. */
|
||||
export async function saveEntry(form: FormData): Promise<Result> {
|
||||
const parsed = entryInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid' };
|
||||
const p = parsed.data;
|
||||
const slug = slugify(p.slug || p.title);
|
||||
if (!slug) return { ok: false, message: 'Use some letters or digits in the title or slug' };
|
||||
const tags = (p.tags ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
const parentId = typeof p.parent_id === 'number' ? p.parent_id : null;
|
||||
if (parentId && parentId === p.id)
|
||||
return { ok: false, message: 'An entry cannot be its own parent' };
|
||||
try {
|
||||
if (p.id) {
|
||||
// Updates go straight to the row (kind and slug may change, which apply_entries keys on).
|
||||
const rows = await client`UPDATE plant.entries SET
|
||||
kind = ${p.kind}, slug = ${slug}, title = ${p.title},
|
||||
summary = ${p.summary || null}, body = ${p.body?.trim() || null},
|
||||
tags = ${tags}, parent_id = ${parentId},
|
||||
sort_order = ${typeof p.sort_order === 'number' ? p.sort_order : 0},
|
||||
source = ${p.source || null}, confidence = ${p.confidence}
|
||||
WHERE id = ${p.id} RETURNING kind, slug`;
|
||||
if (!rows.length) return { ok: false, message: 'That entry no longer exists' };
|
||||
return {
|
||||
ok: true,
|
||||
href: `/trackers/cargill/${rows[0].kind.replace('_', '-')}/${rows[0].slug}`
|
||||
};
|
||||
}
|
||||
const [exists] =
|
||||
await client`SELECT 1 FROM plant.entries WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
if (exists) return { ok: false, message: 'That slug is already used for this kind' };
|
||||
const json: Record<string, unknown> = {
|
||||
kind: p.kind,
|
||||
slug,
|
||||
title: p.title,
|
||||
confidence: p.confidence,
|
||||
source: p.source || 'Stan',
|
||||
tags
|
||||
};
|
||||
if (p.summary) json.summary = p.summary;
|
||||
if (p.body?.trim()) json.body = p.body;
|
||||
if (typeof p.sort_order === 'number') json.sort_order = p.sort_order;
|
||||
await client`SELECT plant.apply_entries(${JSON.stringify({ entries: [json] })}::jsonb)`;
|
||||
if (parentId)
|
||||
await client`UPDATE plant.entries SET parent_id = ${parentId} WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
return { ok: true, href: `/trackers/cargill/${p.kind.replace('_', '-')}/${slug}` };
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
const idOnly = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid entry' };
|
||||
await client`DELETE FROM plant.entries WHERE id = ${p.data.id}`;
|
||||
return { ok: true, href: '/trackers/cargill' };
|
||||
}
|
||||
|
||||
/** Questions: toggle answered (answered=1 marks it, 0 reopens). */
|
||||
export async function answerQuestion(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid question' };
|
||||
const on = form.get('answered') === '1';
|
||||
await client`UPDATE plant.entries
|
||||
SET answered_at = CASE WHEN ${on}::boolean THEN coalesce(answered_at, now()) END
|
||||
WHERE id = ${p.data.id} AND kind = 'question'`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const linkInput = z.object({
|
||||
from_id: z.coerce.number().int().positive(),
|
||||
to_id: z.coerce.number().int().positive(),
|
||||
relation: z.enum(RELATIONS),
|
||||
label: text(80).optional(),
|
||||
dashed: z.literal('1').optional()
|
||||
});
|
||||
|
||||
export async function addLink(form: FormData): Promise<Result> {
|
||||
const parsed = linkInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: 'Pick an entry and a relation' };
|
||||
const l = parsed.data;
|
||||
if (l.from_id === l.to_id) return { ok: false, message: 'An entry cannot link to itself' };
|
||||
await client`INSERT INTO plant.links (from_id, to_id, relation, label, dashed)
|
||||
VALUES (${l.from_id}, ${l.to_id}, ${l.relation}, ${l.label || null}, ${l.dashed === '1'})
|
||||
ON CONFLICT (from_id, to_id, relation) DO UPDATE SET label = excluded.label, dashed = excluded.dashed`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeLink(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid link' };
|
||||
await client`DELETE FROM plant.links WHERE id = ${p.data.id}`;
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
statementCategory,
|
||||
checkBalances,
|
||||
parseAmount,
|
||||
parseRevolutCsv,
|
||||
@@ -21,6 +22,19 @@ Card Payment,Current,2025-08-05 10:00:00,2025-08-05 11:00:00,Kiosk,-3.50,0.00,EU
|
||||
`;
|
||||
|
||||
describe('revolut statement', () => {
|
||||
it('tags interest and dividend rows by type or description', () => {
|
||||
const row = (type: string, description: string, amountCents = 512) => ({
|
||||
type,
|
||||
description,
|
||||
amountCents
|
||||
});
|
||||
expect(statementCategory(row('Interest', 'Savings vault interest'))).toBe('interest');
|
||||
expect(statementCategory(row('', 'Interest PAID EUR Class R IE000AZVL3K0'))).toBe('interest');
|
||||
expect(statementCategory(row('Dividend', 'VWRL'))).toBe('dividend');
|
||||
expect(statementCategory(row('Transfer', 'To EUR Savings', -10000))).toBeNull();
|
||||
expect(statementCategory(row('Card Payment', 'Interest Cafe'))).toBeNull();
|
||||
expect(statementCategory(row('Transfer', 'From Juhee'))).toBeNull();
|
||||
});
|
||||
it('parses amounts in both notations', () => {
|
||||
expect(parseAmount('1,234.56')).toBe(123456);
|
||||
expect(parseAmount('-1.234,56')).toBe(-123456);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { investmentIncomeCategory } from './investment-income';
|
||||
|
||||
// Revolut account statement parsing + import planning (pure; DB work is in scripts/revolut-import.ts).
|
||||
// Handles the app's CSV export (English or Dutch headers, , or ; separated), the Excel export
|
||||
// (rows passed in as a string table) and, defensively, the text of the PDF statement.
|
||||
@@ -341,3 +343,12 @@ export function statementExternalId(row: StatementRow, hash: (s: string) => stri
|
||||
).slice(0, 24)
|
||||
);
|
||||
}
|
||||
|
||||
/** 'interest' / 'dividend' for a statement row that pays investment income, else null. */
|
||||
export function statementCategory(row: Pick<StatementRow, 'amountCents' | 'type' | 'description'>) {
|
||||
return investmentIncomeCategory({
|
||||
amountCents: row.amountCents,
|
||||
kind: row.type,
|
||||
texts: [row.description]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EDITABLE_FIELDS,
|
||||
RESERVED_SLUGS,
|
||||
changedFields,
|
||||
precisionFor,
|
||||
type Category,
|
||||
type PublicTask,
|
||||
type RequestKind,
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
|
||||
const TASK_COLUMNS = sql`
|
||||
t.id, t.slug, t.title, t.description, t.scale::text as scale, t.category,
|
||||
t.location_name, t.country_code, t.lat, t.lon, t.status::text as status, t.source_urls,
|
||||
t.location_name, t.country_code, t.lat, t.lon, t.location_precision::text as location_precision,
|
||||
t.status::text as status, t.source_urls,
|
||||
t.source_date, t.confidence, t.verification_note, t.as_of::text as as_of,
|
||||
t.created_at, t.updated_at, t.deleted_at`;
|
||||
|
||||
@@ -38,17 +40,23 @@ function toDate(v: unknown): Date | null {
|
||||
const iso = (v: unknown) => toDate(v)?.toISOString() ?? '';
|
||||
|
||||
export function toTask(r: Row): PublicTask {
|
||||
const lat = r.lat === null || r.lat === undefined ? null : Number(r.lat);
|
||||
const lon = r.lon === null || r.lon === undefined ? null : Number(r.lon);
|
||||
const scale = r.scale as PublicTask['scale'];
|
||||
return {
|
||||
id: Number(r.id),
|
||||
slug: String(r.slug),
|
||||
title: String(r.title),
|
||||
description: String(r.description),
|
||||
scale: r.scale as PublicTask['scale'],
|
||||
scale,
|
||||
category: String(r.category),
|
||||
locationName: String(r.location_name),
|
||||
countryCode: (r.country_code as string | null) ?? null,
|
||||
lat: r.lat === null || r.lat === undefined ? null : Number(r.lat),
|
||||
lon: r.lon === null || r.lon === undefined ? null : Number(r.lon),
|
||||
lat,
|
||||
lon,
|
||||
locationPrecision:
|
||||
(r.location_precision as PublicTask['locationPrecision'] | null) ??
|
||||
precisionFor(scale, lat, lon),
|
||||
status: r.status as PublicTask['status'],
|
||||
sourceUrls: (r.source_urls as string[]) ?? [],
|
||||
sourceDate: (r.source_date as string | null) ?? null,
|
||||
|
||||
@@ -96,6 +96,20 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
'M20 3v4M22 5h-4M4 17v2M5 18H3'
|
||||
]
|
||||
},
|
||||
cargill: {
|
||||
from: '#a3e635',
|
||||
to: '#4d7c0f',
|
||||
ink: WHITE,
|
||||
// factory: saw-tooth roof, chimney and windows
|
||||
paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1']
|
||||
},
|
||||
math: {
|
||||
from: '#60a5fa',
|
||||
to: '#1e40af',
|
||||
ink: WHITE,
|
||||
// sigma
|
||||
paths: ['M18 7V4H6l6 8-6 8h12v-3']
|
||||
},
|
||||
notes: {
|
||||
from: '#fde68a',
|
||||
to: '#f59e0b',
|
||||
|
||||
155
src/lib/worldtodo/MapPicker.svelte
Normal file
155
src/lib/worldtodo/MapPicker.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type * as Leaflet from 'leaflet';
|
||||
import { untrack } from 'svelte';
|
||||
import { addThemedTiles, loadLeaflet } from './tiles';
|
||||
import MapPin from 'lucide-svelte/icons/map-pin';
|
||||
|
||||
// Optional "Pick on map" for the request form: a click fills the latitude/longitude inputs
|
||||
// (5 decimals, about a metre). The coordinates travel in the change request, so the owner
|
||||
// sees them in the queue before anything is applied.
|
||||
let { lat = $bindable(''), lon = $bindable('') }: { lat?: string; lon?: string } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let el = $state<HTMLDivElement>();
|
||||
let L: typeof Leaflet | null = null;
|
||||
let map: Leaflet.Map | null = null;
|
||||
let marker: Leaflet.CircleMarker | null = null;
|
||||
|
||||
const parsed = () => {
|
||||
const a = Number(lat);
|
||||
const b = Number(lon);
|
||||
return lat !== '' && lon !== '' && Math.abs(a) <= 90 && Math.abs(b) <= 180
|
||||
? ([a, b] as [number, number])
|
||||
: null;
|
||||
};
|
||||
|
||||
function place(at: [number, number] | null) {
|
||||
if (!map || !L) return;
|
||||
if (!at) {
|
||||
marker?.remove();
|
||||
marker = null;
|
||||
return;
|
||||
}
|
||||
if (marker) marker.setLatLng(at);
|
||||
else
|
||||
marker = L.circleMarker(at, {
|
||||
radius: 8,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: '#ea580c',
|
||||
fillOpacity: 1
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !el) return;
|
||||
const node = el;
|
||||
let disposed = false;
|
||||
let stopTiles = () => {};
|
||||
loadLeaflet().then((Lf) => {
|
||||
if (disposed) return;
|
||||
L = Lf;
|
||||
const start = untrack(parsed);
|
||||
const m = Lf.map(node, { scrollWheelZoom: false, worldCopyJump: true, minZoom: 1 });
|
||||
map = m;
|
||||
stopTiles = addThemedTiles(Lf, m);
|
||||
if (start) m.setView(start, 12);
|
||||
else m.setView([30, 10], 1);
|
||||
m.on('focus', () => m.scrollWheelZoom.enable());
|
||||
m.on('blur', () => m.scrollWheelZoom.disable());
|
||||
m.on('click', (e: Leaflet.LeafletMouseEvent) => {
|
||||
m.scrollWheelZoom.enable();
|
||||
const w = e.latlng.wrap();
|
||||
lat = w.lat.toFixed(5);
|
||||
lon = w.lng.toFixed(5);
|
||||
});
|
||||
untrack(() => place(start));
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopTiles();
|
||||
map?.remove();
|
||||
map = null;
|
||||
marker = null;
|
||||
};
|
||||
});
|
||||
// Typed coordinates move the marker too.
|
||||
$effect(() => {
|
||||
void lat;
|
||||
void lon;
|
||||
untrack(() => place(parsed()));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button type="button" class="toggle" aria-expanded={open} onclick={() => (open = !open)}>
|
||||
<MapPin size={14} aria-hidden="true" />{open ? 'Hide map' : 'Pick on map'}
|
||||
</button>
|
||||
{#if lat || lon}
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
onclick={() => {
|
||||
lat = '';
|
||||
lon = '';
|
||||
}}>Clear position</button
|
||||
>
|
||||
{/if}
|
||||
{#if open}
|
||||
<div class="mini" bind:this={el} aria-label="Click the map to set the position"></div>
|
||||
<p class="help">
|
||||
Click where the problem is; zoom in for a street. This fills the fields above.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Keyless fallback (OSM tiles) darkened to sit in the dark theme. */
|
||||
:global(.wt-tiles-dim) {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6);
|
||||
}
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--site-text);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle:hover,
|
||||
.toggle:focus-visible {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.mini {
|
||||
flex-basis: 100%;
|
||||
height: 260px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
overflow: hidden;
|
||||
background: #0e0e0e;
|
||||
cursor: crosshair;
|
||||
z-index: 0;
|
||||
}
|
||||
.mini :global(.leaflet-container) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.mini :global(.leaflet-control-attribution) {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.help {
|
||||
flex-basis: 100%;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import IconTile from './IconTile.svelte';
|
||||
import MapPicker from './MapPicker.svelte';
|
||||
import { SCALE_ICONS } from './icons';
|
||||
import {
|
||||
SCALE_INFO,
|
||||
@@ -212,6 +213,7 @@
|
||||
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<MapPicker bind:lat bind:lon />
|
||||
</details>
|
||||
|
||||
<fieldset class="field" aria-describedby={described('source_urls', true)}>
|
||||
|
||||
760
src/lib/worldtodo/WorldMap.svelte
Normal file
760
src/lib/worldtodo/WorldMap.svelte
Normal file
@@ -0,0 +1,760 @@
|
||||
<script lang="ts">
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type * as Leaflet from 'leaflet';
|
||||
import { mount, unmount, untrack, type Component } from 'svelte';
|
||||
import { categoryIcon } from './icons';
|
||||
import { clusterPoints, coordKey, ringOffsets } from './cluster';
|
||||
import { addThemedTiles, loadLeaflet } from './tiles';
|
||||
import {
|
||||
SCALE_INFO,
|
||||
STATUS_LABELS,
|
||||
WORLDTODO_SCALES,
|
||||
type Category,
|
||||
type PublicTask,
|
||||
type Scale
|
||||
} from './shared';
|
||||
|
||||
// The task map (List / Map toggle, tracker #4641): one pin per task with coordinates,
|
||||
// coloured by category and sized by scale; clusters with counts when zoomed out; tasks on
|
||||
// the same centroid fan out in a ring from zoom 6. Planet tasks never get a pin (the page
|
||||
// shows them in the Global strip). Pins are focusable buttons; the list stays the full
|
||||
// alternative to the map.
|
||||
let {
|
||||
tasks,
|
||||
categories,
|
||||
highlight = null,
|
||||
fitKey = ''
|
||||
}: {
|
||||
tasks: PublicTask[];
|
||||
categories: Category[];
|
||||
/** Slug whose pin (or cluster) is emphasised, e.g. while hovering its list card. */
|
||||
highlight?: string | null;
|
||||
/** Refit the view when this changes (filters), not on live updates. */
|
||||
fitKey?: string;
|
||||
} = $props();
|
||||
|
||||
const BASE = '/projects/worldwide-todo';
|
||||
const PIN: Record<Scale, number> = { street: 16, city: 20, country: 26, region: 32, planet: 36 };
|
||||
const FOCUS_ZOOM: Record<Scale, number> = {
|
||||
street: 16,
|
||||
city: 12,
|
||||
country: 6,
|
||||
region: 5,
|
||||
planet: 3
|
||||
};
|
||||
const SPREAD_FROM = 6;
|
||||
const UNCLUSTER_FROM = 10;
|
||||
|
||||
let el: HTMLDivElement;
|
||||
let L: typeof Leaflet | null = null;
|
||||
let map: Leaflet.Map | null = null;
|
||||
let layer: Leaflet.LayerGroup | null = null;
|
||||
let ready = $state(false);
|
||||
let failed = $state(false);
|
||||
let scrollOn = $state(false);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const pins = new Map<string, Leaflet.Marker>();
|
||||
/** slug → the element showing it (its own pin or the cluster that holds it). */
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const shownBy = new Map<string, HTMLElement>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const clusterOf = new Map<string, { at: Leaflet.LatLng; members: PublicTask[] }>();
|
||||
|
||||
const located = $derived(
|
||||
tasks.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
|
||||
);
|
||||
const catBySlug = $derived(new Map(categories.map((c) => [c.slug, c])));
|
||||
const usedCats = $derived(categories.filter((c) => located.some((t) => t.category === c.slug)));
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(
|
||||
/[&<>"']/g,
|
||||
(ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]!
|
||||
);
|
||||
const colourOf = (t: PublicTask) => {
|
||||
const c = catBySlug.get(t.category)?.colour ?? '';
|
||||
return /^#[0-9a-f]{3,8}$/i.test(c) ? c : '#737373';
|
||||
};
|
||||
const label = (t: PublicTask) =>
|
||||
`${t.title}, ${SCALE_INFO[t.scale].label.toLowerCase()}, ${STATUS_LABELS[t.status].toLowerCase()}`;
|
||||
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const svgCache = new Map<string, string>();
|
||||
function iconSvg(key: string | undefined) {
|
||||
const k = key ?? '';
|
||||
let svg = svgCache.get(k);
|
||||
if (svg === undefined) {
|
||||
const host = document.createElement('div');
|
||||
const c = mount(categoryIcon(key) as unknown as Component<Record<string, unknown>>, {
|
||||
target: host,
|
||||
props: { size: 14, strokeWidth: 2.5, 'aria-hidden': 'true' }
|
||||
});
|
||||
svg = host.innerHTML.replace(/<!--[^>]*-->/g, '');
|
||||
unmount(c);
|
||||
svgCache.set(k, svg);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
const CHECK =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
|
||||
function pinHtml(t: PublicTask) {
|
||||
const inner =
|
||||
t.status === 'done'
|
||||
? CHECK
|
||||
: PIN[t.scale] >= 20
|
||||
? iconSvg(catBySlug.get(t.category)?.icon)
|
||||
: '';
|
||||
return `<span class="wt-pin ${t.status} ${t.locationPrecision}" style="--c:${colourOf(t)};--s:${PIN[t.scale]}px">${inner}</span>`;
|
||||
}
|
||||
|
||||
function popupHtml(t: PublicTask) {
|
||||
const cat = catBySlug.get(t.category);
|
||||
const href = `${BASE}/${encodeURIComponent(t.slug)}`;
|
||||
const change = `${BASE}/request?kind=edit&task=${encodeURIComponent(t.slug)}`;
|
||||
return `<div class="wt-pop">
|
||||
<div class="wt-chips">
|
||||
<span class="wt-chip">${esc(SCALE_INFO[t.scale].label)}</span>
|
||||
<span class="wt-chip"><i style="background:${colourOf(t)}"></i>${esc(cat?.label ?? t.category)}</span>
|
||||
<span class="wt-status ${t.status}">${esc(STATUS_LABELS[t.status])}</span>
|
||||
</div>
|
||||
<a class="wt-title" href="${href}">${esc(t.title)}</a>
|
||||
<p class="wt-where">${esc(t.locationName)}${t.locationPrecision === 'approximate' ? ' · <em>approximate location</em>' : ''}</p>
|
||||
<p class="wt-desc">${esc(t.description)}</p>
|
||||
<div class="wt-actions">
|
||||
<a class="wt-btn primary" href="${href}">View task</a>
|
||||
<a class="wt-btn" href="${change}">Request change</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clusterPopupHtml(members: PublicTask[]) {
|
||||
const items = members
|
||||
.map(
|
||||
(t) =>
|
||||
`<li><i style="background:${colourOf(t)}"></i><a href="${BASE}/${encodeURIComponent(t.slug)}">${esc(t.title)}</a><span class="wt-status ${t.status}">${esc(STATUS_LABELS[t.status])}</span></li>`
|
||||
)
|
||||
.join('');
|
||||
return `<div class="wt-pop"><p class="wt-where">${members.length} tasks here</p><ul class="wt-list">${items}</ul></div>`;
|
||||
}
|
||||
|
||||
const popupOptions = () => ({
|
||||
className: 'wt-popup',
|
||||
maxWidth: Math.min(300, (el?.clientWidth ?? 320) - 48),
|
||||
minWidth: Math.min(220, (el?.clientWidth ?? 320) - 64),
|
||||
autoPanPadding: [16, 16] as [number, number]
|
||||
});
|
||||
|
||||
function render() {
|
||||
if (!map || !L || !layer) return;
|
||||
const Lf = L;
|
||||
const m = map;
|
||||
layer.clearLayers();
|
||||
pins.clear();
|
||||
shownBy.clear();
|
||||
clusterOf.clear();
|
||||
const z = m.getZoom();
|
||||
|
||||
type P = { t: PublicTask; x: number; y: number; weight: number; at: Leaflet.LatLng };
|
||||
const all: P[] = located.map((t) => {
|
||||
const at = Lf.latLng(t.lat!, t.lon!);
|
||||
const p = m.project(at, z);
|
||||
return { t, x: p.x, y: p.y, weight: PIN[t.scale], at };
|
||||
});
|
||||
// From SPREAD_FROM, tasks sharing one coordinate (country centroids) fan out in a ring
|
||||
// and never cluster; from UNCLUSTER_FROM nothing clusters.
|
||||
const fanned: P[][] = [];
|
||||
let rest = all;
|
||||
if (z >= SPREAD_FROM) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const byCoord = new Map<string, P[]>();
|
||||
for (const p of all) {
|
||||
const k = coordKey(p.t.lat!, p.t.lon!);
|
||||
byCoord.set(k, [...(byCoord.get(k) ?? []), p]);
|
||||
}
|
||||
for (const g of byCoord.values()) if (g.length > 1) fanned.push(g);
|
||||
rest = all.filter((p) => !fanned.some((g) => g.includes(p)));
|
||||
}
|
||||
const groups: P[][] = [
|
||||
...clusterPoints(rest, z >= UNCLUSTER_FROM ? 0 : z >= SPREAD_FROM ? 36 : 40),
|
||||
...fanned.flatMap((g) => {
|
||||
const ring = ringOffsets(g.length);
|
||||
return g.map((p, i) => [
|
||||
{ ...p, at: m.unproject([p.x + ring[i].dx, p.y + ring[i].dy], z) }
|
||||
]);
|
||||
})
|
||||
];
|
||||
|
||||
for (const g of groups) {
|
||||
if (g.length === 1) {
|
||||
const { t, at } = g[0];
|
||||
const s = PIN[t.scale];
|
||||
const marker = Lf.marker(at, {
|
||||
icon: Lf.divIcon({
|
||||
className: 'wt-pin-wrap',
|
||||
html: pinHtml(t),
|
||||
iconSize: [s, s],
|
||||
iconAnchor: [s / 2, s / 2],
|
||||
popupAnchor: [0, -s / 2]
|
||||
}),
|
||||
keyboard: true,
|
||||
riseOnHover: true,
|
||||
zIndexOffset: s * 10
|
||||
});
|
||||
marker.bindPopup(popupHtml(t), popupOptions());
|
||||
marker.on('add', () => {
|
||||
const e = marker.getElement();
|
||||
if (!e) return;
|
||||
e.setAttribute('role', 'button');
|
||||
e.setAttribute('aria-label', label(t));
|
||||
e.removeAttribute('title');
|
||||
shownBy.set(t.slug, e);
|
||||
if (t.slug === highlight) e.classList.add('wt-hl');
|
||||
});
|
||||
pins.set(t.slug, marker);
|
||||
layer.addLayer(marker);
|
||||
continue;
|
||||
}
|
||||
const members = g.map((p) => p.t);
|
||||
const bounds = Lf.latLngBounds(g.map((p) => p.at));
|
||||
const center = bounds.getCenter();
|
||||
const colours = [...new Set(members.map(colourOf))].slice(0, 8);
|
||||
const step = 100 / colours.length;
|
||||
const ring = colours.map((c, i) => `${c} ${i * step}% ${(i + 1) * step}%`).join(', ');
|
||||
const size = Math.min(52, 30 + members.length * 2);
|
||||
const marker = Lf.marker(center, {
|
||||
icon: Lf.divIcon({
|
||||
className: 'wt-pin-wrap',
|
||||
html: `<span class="wt-cluster" style="--s:${size}px;background:conic-gradient(${ring})"><span>${members.length}</span></span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
}),
|
||||
keyboard: true,
|
||||
zIndexOffset: 1000
|
||||
});
|
||||
const entry = { at: center, members };
|
||||
marker.on('click', () => {
|
||||
const sameSpot = bounds.getNorthEast().distanceTo(bounds.getSouthWest()) < 200;
|
||||
if (sameSpot || m.getZoom() >= UNCLUSTER_FROM)
|
||||
Lf.popup(popupOptions())
|
||||
.setLatLng(center)
|
||||
.setContent(clusterPopupHtml(members))
|
||||
.openOn(m);
|
||||
else m.flyToBounds(bounds, { padding: [56, 56], maxZoom: UNCLUSTER_FROM, duration: 0.6 });
|
||||
});
|
||||
marker.on('add', () => {
|
||||
const e = marker.getElement();
|
||||
if (!e) return;
|
||||
e.setAttribute('role', 'button');
|
||||
e.setAttribute('aria-label', `${members.length} tasks here, zoom in`);
|
||||
e.removeAttribute('title');
|
||||
for (const t of members) shownBy.set(t.slug, e);
|
||||
if (highlight && members.some((t) => t.slug === highlight)) e.classList.add('wt-hl');
|
||||
});
|
||||
for (const t of members) clusterOf.set(t.slug, entry);
|
||||
layer.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function fit() {
|
||||
if (!map || !L) return;
|
||||
const pts = located.map((t) => [t.lat!, t.lon!] as [number, number]);
|
||||
if (!pts.length) map.setView([25, 10], 2);
|
||||
else if (pts.length === 1) map.setView(pts[0], FOCUS_ZOOM[located[0].scale]);
|
||||
else map.fitBounds(L.latLngBounds(pts), { padding: [36, 36], maxZoom: 12 });
|
||||
}
|
||||
|
||||
/** Fly to a task's pin and open its popup (list card click in map view). */
|
||||
export function focusTask(slug: string) {
|
||||
const t = located.find((x) => x.slug === slug);
|
||||
if (!t || !map) return;
|
||||
const m = map;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top < 0 || r.bottom > window.innerHeight)
|
||||
el.scrollIntoView({
|
||||
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
const zoom = Math.max(m.getZoom(), FOCUS_ZOOM[t.scale]);
|
||||
m.once('moveend', () => {
|
||||
render();
|
||||
const pin = pins.get(slug);
|
||||
if (pin) pin.openPopup();
|
||||
else {
|
||||
const c = clusterOf.get(slug);
|
||||
if (c && L)
|
||||
L.popup(popupOptions()).setLatLng(c.at).setContent(clusterPopupHtml(c.members)).openOn(m);
|
||||
}
|
||||
});
|
||||
m.flyTo([t.lat!, t.lon!], zoom, { duration: 0.8 });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
let disposed = false;
|
||||
let stopTiles = () => {};
|
||||
loadLeaflet()
|
||||
.then((Lf) => {
|
||||
if (disposed) return;
|
||||
L = Lf;
|
||||
const m = Lf.map(el, {
|
||||
worldCopyJump: true,
|
||||
scrollWheelZoom: false,
|
||||
minZoom: 1,
|
||||
maxZoom: 18,
|
||||
zoomSnap: 0.5
|
||||
});
|
||||
map = m;
|
||||
stopTiles = addThemedTiles(Lf, m);
|
||||
layer = Lf.layerGroup().addTo(m);
|
||||
// Scroll zoom only after the map is clicked or focused, so the page keeps scrolling.
|
||||
const on = () => {
|
||||
m.scrollWheelZoom.enable();
|
||||
scrollOn = true;
|
||||
};
|
||||
const off = () => {
|
||||
m.scrollWheelZoom.disable();
|
||||
scrollOn = false;
|
||||
};
|
||||
m.on('click focus', on);
|
||||
m.on('blur', off);
|
||||
el.addEventListener('mouseleave', off);
|
||||
m.on('zoomend', render);
|
||||
untrack(fit);
|
||||
render();
|
||||
ready = true;
|
||||
})
|
||||
.catch(() => (failed = true));
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopTiles();
|
||||
map?.remove();
|
||||
map = null;
|
||||
layer = null;
|
||||
};
|
||||
});
|
||||
|
||||
// Live updates and filter changes re-render the pins; only filter changes refit the view.
|
||||
$effect(() => {
|
||||
void located;
|
||||
void catBySlug;
|
||||
if (ready) untrack(render);
|
||||
});
|
||||
let lastFit = untrack(() => fitKey);
|
||||
$effect(() => {
|
||||
const k = fitKey;
|
||||
if (!ready || k === lastFit) return;
|
||||
lastFit = k;
|
||||
untrack(fit);
|
||||
});
|
||||
$effect(() => {
|
||||
const h = highlight;
|
||||
if (!ready) return;
|
||||
untrack(() => {
|
||||
for (const e of el.querySelectorAll('.wt-hl')) e.classList.remove('wt-hl');
|
||||
if (h) shownBy.get(h)?.classList.add('wt-hl');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="wt-map-wrap">
|
||||
<div
|
||||
class="wt-map"
|
||||
bind:this={el}
|
||||
role="region"
|
||||
aria-label="Map of tasks. Each pin is a button; the list below has every task too."
|
||||
></div>
|
||||
{#if failed}
|
||||
<p class="overlay">The map could not load. The list below has every task.</p>
|
||||
{:else if ready && !scrollOn}
|
||||
<span class="hint" aria-hidden="true">Click the map to zoom with scroll</span>
|
||||
{/if}
|
||||
<details class="legend">
|
||||
<summary>Legend</summary>
|
||||
<div class="legend-body">
|
||||
<ul aria-label="Categories">
|
||||
{#each usedCats as c (c.slug)}
|
||||
<li><i class="dot" style="background:{c.colour}"></i>{c.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul aria-label="Scales" class="scales">
|
||||
{#each WORLDTODO_SCALES.filter((s) => s !== 'planet') as s (s)}
|
||||
<li>
|
||||
<i class="dot size" style="--s:{PIN[s]}px"></i>{SCALE_INFO[s].label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul aria-label="Pin styles">
|
||||
<li><i class="dot ring"></i>In progress</li>
|
||||
<li><i class="dot done"></i>Done</li>
|
||||
<li><i class="dot approx"></i>Approximate (region, country)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Keyless fallback (OSM tiles) darkened to sit in the dark theme. */
|
||||
:global(.wt-tiles-dim) {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6);
|
||||
}
|
||||
.wt-map-wrap {
|
||||
position: relative;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.85rem;
|
||||
overflow: hidden;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.wt-map {
|
||||
height: min(70vh, 720px);
|
||||
min-height: 320px;
|
||||
background: #0e0e0e;
|
||||
z-index: 0;
|
||||
}
|
||||
:global(html:not(.dark)) .wt-map {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
.hint,
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
z-index: 500;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
background: color-mix(in srgb, var(--site-bg) 85%, transparent);
|
||||
border: 1px solid var(--site-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
.overlay {
|
||||
inset: 40% 1rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
.legend {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
bottom: 1.6rem;
|
||||
z-index: 500;
|
||||
max-width: calc(100% - 1.2rem);
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in srgb, var(--site-bg) 90%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.legend summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.legend-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1.2rem;
|
||||
padding: 0.4rem 0 0.15rem;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.legend ul {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
align-content: start;
|
||||
}
|
||||
.legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid #fff;
|
||||
flex: none;
|
||||
}
|
||||
.dot.size {
|
||||
width: calc(var(--s) * 0.6);
|
||||
height: calc(var(--s) * 0.6);
|
||||
background: var(--site-muted);
|
||||
}
|
||||
.dot.ring {
|
||||
background: #737373;
|
||||
box-shadow: 0 0 0 2px #facc15;
|
||||
}
|
||||
.dot.done {
|
||||
background: #525252;
|
||||
border-color: #a3a3a3;
|
||||
}
|
||||
.dot.approx {
|
||||
background: #737373;
|
||||
outline: 1.5px dashed #a3a3a3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Leaflet-made DOM (pins, clusters, popups) lives inside .wt-map. */
|
||||
.wt-map :global(.wt-pin-wrap) {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wt-map :global(.wt-pin-wrap:focus-visible) {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.wt-map :global(.wt-pin) {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--s);
|
||||
height: var(--s);
|
||||
border-radius: 999px;
|
||||
background: var(--c);
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.55);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wt-map :global(.wt-pin svg) {
|
||||
width: 62%;
|
||||
height: 62%;
|
||||
}
|
||||
.wt-map :global(.wt-pin.approximate) {
|
||||
outline: 2px dashed color-mix(in srgb, var(--c) 60%, #fff);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.wt-map :global(.wt-pin.done) {
|
||||
background: #4b4b4b;
|
||||
border-color: #a3a3a3;
|
||||
color: #e5e5e5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.wt-map :global(.wt-pin.in_progress::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--c);
|
||||
animation: wt-pulse 1.8s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes -global-wt-pulse {
|
||||
from {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.95;
|
||||
}
|
||||
to {
|
||||
transform: scale(1.7);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.wt-map :global(.wt-pin-wrap:hover .wt-pin),
|
||||
.wt-map :global(.wt-pin-wrap:focus-visible .wt-pin),
|
||||
.wt-map :global(.wt-hl .wt-pin),
|
||||
.wt-map :global(.wt-pin-wrap:hover .wt-cluster),
|
||||
.wt-map :global(.wt-hl .wt-cluster) {
|
||||
transform: scale(1.35);
|
||||
}
|
||||
.wt-map :global(.wt-pin-wrap.wt-hl) {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
.wt-map :global(.wt-hl .wt-pin) {
|
||||
box-shadow:
|
||||
0 0 0 3px #fff,
|
||||
0 2px 12px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
.wt-map :global(.wt-cluster) {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: var(--s);
|
||||
height: var(--s);
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 0.55);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wt-map :global(.wt-cluster span) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wt-map :global(.leaflet-control-attribution) {
|
||||
background: color-mix(in srgb, var(--site-bg) 80%, transparent);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.wt-map :global(.leaflet-control-attribution a) {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wt-map :global(.leaflet-control-zoom) {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wt-map :global(.leaflet-control-zoom a) {
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-border);
|
||||
}
|
||||
.wt-map :global(.wt-popup .leaflet-popup-content-wrapper),
|
||||
.wt-map :global(.wt-popup .leaflet-popup-tip) {
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
border: 1px solid var(--site-border);
|
||||
box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
||||
}
|
||||
.wt-map :global(.wt-popup .leaflet-popup-content-wrapper) {
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
.wt-map :global(.wt-popup .leaflet-popup-content) {
|
||||
margin: 0.8rem 0.9rem;
|
||||
font: inherit;
|
||||
}
|
||||
.wt-map :global(.wt-popup a.leaflet-popup-close-button) {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.wt-map :global(.wt-pop) {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.wt-map :global(.wt-chips) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.wt-map :global(.wt-chip),
|
||||
.wt-map :global(.wt-status) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wt-map :global(.wt-chip i),
|
||||
.wt-map :global(.wt-list i) {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
}
|
||||
.wt-map :global(.wt-status) {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wt-map :global(.wt-status.done) {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.wt-map :global(.wt-title) {
|
||||
color: var(--site-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wt-map :global(.wt-title:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wt-map :global(.wt-pop p) {
|
||||
margin: 0;
|
||||
}
|
||||
.wt-map :global(.wt-where) {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wt-map :global(.wt-desc) {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: var(--site-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wt-map :global(.wt-actions) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.wt-map :global(.wt-btn) {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
color: var(--site-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 550;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wt-map :global(.wt-btn:hover),
|
||||
.wt-map :global(.wt-btn:focus-visible) {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.wt-map :global(.wt-btn.primary) {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.wt-map :global(.wt-list) {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.wt-map :global(.wt-list li) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wt-map :global(.wt-list a) {
|
||||
flex: 1;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wt-map :global(.wt-list a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wt-map {
|
||||
height: 60vh;
|
||||
}
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wt-map :global(.wt-pin.in_progress::after) {
|
||||
animation: none;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.wt-map :global(.wt-pin),
|
||||
.wt-map :global(.wt-cluster) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
src/lib/worldtodo/cluster.test.ts
Normal file
40
src/lib/worldtodo/cluster.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clusterPoints, coordKey, ringOffsets } from './cluster';
|
||||
|
||||
const pt = (x: number, y: number, weight = 1) => ({ x, y, weight });
|
||||
|
||||
describe('clusterPoints', () => {
|
||||
it('groups points within the radius and keeps far ones apart', () => {
|
||||
const a = pt(0, 0);
|
||||
const b = pt(10, 10);
|
||||
const c = pt(200, 0);
|
||||
const groups = clusterPoints([a, b, c], 40);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find((g) => g.includes(a))).toContain(b);
|
||||
expect(groups.find((g) => g.includes(c))).toEqual([c]);
|
||||
});
|
||||
it('lets the heaviest point anchor the cluster', () => {
|
||||
const small = pt(0, 0, 1);
|
||||
const big = pt(30, 0, 5);
|
||||
const far = pt(65, 0, 1);
|
||||
const groups = clusterPoints([small, big, far], 40);
|
||||
expect(groups[0][0]).toBe(big);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
it('radius 0 keeps every point alone', () => {
|
||||
expect(clusterPoints([pt(0, 0), pt(0, 0)], 0)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ringOffsets', () => {
|
||||
it('is a no-op for one pin and spreads several pins apart', () => {
|
||||
expect(ringOffsets(1)).toEqual([{ dx: 0, dy: 0 }]);
|
||||
const ring = ringOffsets(4);
|
||||
expect(ring).toHaveLength(4);
|
||||
const keys = new Set(ring.map((o) => `${o.dx},${o.dy}`));
|
||||
expect(keys.size).toBe(4);
|
||||
});
|
||||
it('rounds coordinates for grouping', () => {
|
||||
expect(coordKey(52.2, 5.3)).toBe(coordKey(52.20001, 5.30002));
|
||||
});
|
||||
});
|
||||
45
src/lib/worldtodo/cluster.ts
Normal file
45
src/lib/worldtodo/cluster.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Tiny screen-space clustering for the task map (a few hundred pins at most, so O(n²) is fine
|
||||
// and avoids a plugin). Points are in projected pixels at the current zoom.
|
||||
|
||||
export type ScreenPoint = { x: number; y: number; weight: number };
|
||||
|
||||
/**
|
||||
* Greedy clustering: the heaviest unassigned point (largest scale) takes every unassigned
|
||||
* point within `radius` px. Returns groups in that order; radius 0 keeps every point alone.
|
||||
*/
|
||||
export function clusterPoints<T extends ScreenPoint>(points: T[], radius: number): T[][] {
|
||||
const order = [...points].sort((a, b) => b.weight - a.weight);
|
||||
const used = new Set<T>();
|
||||
const groups: T[][] = [];
|
||||
const r2 = radius * radius;
|
||||
for (const p of order) {
|
||||
if (used.has(p)) continue;
|
||||
used.add(p);
|
||||
const group = [p];
|
||||
if (radius > 0)
|
||||
for (const q of order) {
|
||||
if (used.has(q)) continue;
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
if (dx * dx + dy * dy <= r2) {
|
||||
used.add(q);
|
||||
group.push(q);
|
||||
}
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Offsets (px) that fan out `n` pins sharing one coordinate in a small ring. */
|
||||
export function ringOffsets(n: number): { dx: number; dy: number }[] {
|
||||
if (n <= 1) return [{ dx: 0, dy: 0 }];
|
||||
const r = 16 + n * 3;
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const a = (2 * Math.PI * i) / n - Math.PI / 2;
|
||||
return { dx: Math.round(Math.cos(a) * r), dy: Math.round(Math.sin(a) * r) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rounded lat/lon key, so tasks on the same centroid can be fanned out. */
|
||||
export const coordKey = (lat: number, lon: number) => `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
@@ -8,6 +8,19 @@ export const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||
export type RequestKind = (typeof WORLDTODO_REQUEST_KINDS)[number];
|
||||
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||
export type RequestStatus = (typeof WORLDTODO_REQUEST_STATUSES)[number];
|
||||
export const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
|
||||
export type LocationPrecision = (typeof WORLDTODO_LOCATION_PRECISIONS)[number];
|
||||
|
||||
/** Same rule as the tasks_location_precision trigger: what kind of pin a task gets. */
|
||||
export function precisionFor(
|
||||
scale: Scale,
|
||||
lat: number | null | undefined,
|
||||
lon: number | null | undefined
|
||||
): LocationPrecision {
|
||||
if (lat === null || lat === undefined || lon === null || lon === undefined) return 'none';
|
||||
if (scale === 'planet') return 'none';
|
||||
return scale === 'street' || scale === 'city' ? 'exact' : 'approximate';
|
||||
}
|
||||
|
||||
/** The only keys a request payload may carry (checked server-side). */
|
||||
export const EDITABLE_FIELDS = [
|
||||
@@ -66,6 +79,8 @@ export type PublicTask = {
|
||||
countryCode: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
/** Map pin kind (see precisionFor). */
|
||||
locationPrecision: LocationPrecision;
|
||||
status: Status;
|
||||
sourceUrls: string[];
|
||||
sourceDate: string | null;
|
||||
|
||||
54
src/lib/worldtodo/tiles.ts
Normal file
54
src/lib/worldtodo/tiles.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type * as Leaflet from 'leaflet';
|
||||
import { PUBLIC_MAPTILER_API_KEY } from '$app/env/public';
|
||||
|
||||
// MapTiler "Dataviz" basemaps on OpenStreetMap data (the site's existing public MapTiler
|
||||
// key): dark in the dark theme, light in the light one, swapped live when the theme
|
||||
// toggles. Without a key (local dev) it falls back to the standard OSM tiles, darkened
|
||||
// with a CSS filter in the dark theme. CARTO's raster tiles need a key since Sept 2026.
|
||||
|
||||
const isDark = () => document.documentElement.classList.contains('dark');
|
||||
|
||||
const OSM =
|
||||
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors';
|
||||
const MAPTILER =
|
||||
'© <a href="https://www.maptiler.com/copyright/" target="_blank" rel="noopener">MapTiler</a>';
|
||||
|
||||
function source(dark: boolean): { url: string; attribution: string; filtered: boolean } {
|
||||
if (PUBLIC_MAPTILER_API_KEY) {
|
||||
const style = dark ? 'dataviz-dark' : 'dataviz';
|
||||
return {
|
||||
url: `https://api.maptiler.com/maps/${style}/256/{z}/{x}/{y}{r}.png?key=${encodeURIComponent(PUBLIC_MAPTILER_API_KEY)}`,
|
||||
attribution: `${MAPTILER} ${OSM}`,
|
||||
filtered: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: OSM,
|
||||
filtered: dark
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds the themed tile layer to `map`; returns a cleanup for the theme observer. */
|
||||
export function addThemedTiles(L: typeof Leaflet, map: Leaflet.Map): () => void {
|
||||
const first = source(isDark());
|
||||
const layer = L.tileLayer(first.url, {
|
||||
maxZoom: 19,
|
||||
attribution: first.attribution,
|
||||
className: first.filtered ? 'wt-tiles-dim' : ''
|
||||
}).addTo(map);
|
||||
const apply = () => {
|
||||
const s = source(isDark());
|
||||
layer.getContainer()?.classList.toggle('wt-tiles-dim', s.filtered);
|
||||
layer.setUrl(s.url);
|
||||
};
|
||||
const observer = new MutationObserver(apply);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/** Leaflet is browser-only: load it on demand (never during SSR). */
|
||||
export async function loadLeaflet(): Promise<typeof Leaflet> {
|
||||
const mod = await import('leaflet');
|
||||
return (mod.default ?? mod) as typeof Leaflet;
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { kinds, overview, searchSkins, streams } from '#lib/server/cs-prices.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [stats, dimensions, markets] = await Promise.all([
|
||||
db.execute(sql`SELECT
|
||||
(SELECT count(*)::int FROM cs.items) AS items,
|
||||
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
|
||||
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
|
||||
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
const kind = url.searchParams.get('kind') ?? '';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const [stats, dimensions, markets, skins] = await Promise.all([
|
||||
overview(),
|
||||
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
||||
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
|
||||
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
|
||||
streams(),
|
||||
searchSkins(q, kind, sort)
|
||||
]);
|
||||
return {
|
||||
stats: stats[0] as {
|
||||
items: number;
|
||||
observations: string;
|
||||
earliest: Date | null;
|
||||
latest: Date | null;
|
||||
},
|
||||
stats,
|
||||
dimensions: dimensions as unknown as { key: string; values: number }[],
|
||||
markets: markets as unknown as { source: string; currency: string; kind: string }[]
|
||||
markets,
|
||||
kinds: kinds.map(([value, label]) => ({ value, label })),
|
||||
browse: { q, kind, sort, skins }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,70 @@
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
// Browse: every skin, sticker, case, agent, … with its cheapest Skinport listing today.
|
||||
const euro = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
||||
type Hit = (typeof data.browse.skins)[number];
|
||||
// svelte-ignore state_referenced_locally
|
||||
let browseQuery = $state(data.browse.q);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let browseKind = $state(data.browse.kind);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let browseSort = $state(data.browse.sort);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let hits = $state<Hit[]>(data.browse.skins);
|
||||
let browsing = $state(false);
|
||||
let moreLeft = $state(true);
|
||||
let firstBrowse = true;
|
||||
async function fetchHits(offset: number, signal?: AbortSignal) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
||||
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
|
||||
params.set('offset', String(offset));
|
||||
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
|
||||
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
||||
return (await response.json()).skins as Hit[];
|
||||
}
|
||||
$effect(() => {
|
||||
void browseQuery;
|
||||
void browseKind;
|
||||
void browseSort;
|
||||
if (firstBrowse) {
|
||||
firstBrowse = false;
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
browsing = true;
|
||||
try {
|
||||
hits = await fetchHits(0, controller.signal);
|
||||
moreLeft = hits.length >= 60;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
||||
const params = new URLSearchParams();
|
||||
if (browseQuery) params.set('q', browseQuery);
|
||||
if (browseKind) params.set('kind', browseKind);
|
||||
if (browseSort !== 'popular') params.set('sort', browseSort);
|
||||
history.replaceState(history.state, '', params.size ? `?${params}` : location.pathname);
|
||||
} catch {
|
||||
/* aborted or failed; keep the last results */
|
||||
} finally {
|
||||
if (!controller.signal.aborted) browsing = false;
|
||||
}
|
||||
}, 200);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
async function loadMore() {
|
||||
browsing = true;
|
||||
try {
|
||||
const next = await fetchHits(hits.length);
|
||||
hits = [...hits, ...next];
|
||||
moreLeft = next.length >= 60;
|
||||
} finally {
|
||||
browsing = false;
|
||||
}
|
||||
}
|
||||
type Result = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
@@ -40,15 +104,12 @@
|
||||
};
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
let dimension = $state('item');
|
||||
let sourceChoice = $state('steam-market|USD');
|
||||
let kind = $state('sale-median');
|
||||
let sourceChoice = $state('skinport|EUR');
|
||||
let kind = $state('lowest-listing');
|
||||
let markets = $derived(
|
||||
data.markets.length
|
||||
? data.markets
|
||||
: [
|
||||
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
|
||||
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
|
||||
]
|
||||
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
|
||||
);
|
||||
let sourceOptions = $derived([
|
||||
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
|
||||
@@ -90,7 +151,7 @@
|
||||
sourceChoice = value;
|
||||
kind =
|
||||
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
|
||||
'sale-median';
|
||||
'lowest-listing';
|
||||
}
|
||||
function add(row: Result) {
|
||||
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
|
||||
@@ -115,8 +176,8 @@
|
||||
}
|
||||
const to = new SvelteDate();
|
||||
const from =
|
||||
range === 'all' && data.stats.earliest
|
||||
? new SvelteDate(data.stats.earliest)
|
||||
range === 'all' && data.stats.earliest_day
|
||||
? new SvelteDate(data.stats.earliest_day)
|
||||
: new SvelteDate(to);
|
||||
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
|
||||
if (range === '24h') from.setTime(to.getTime() - 86_400_000);
|
||||
@@ -124,10 +185,12 @@
|
||||
if (range === '30d') from.setUTCDate(from.getUTCDate() - 30);
|
||||
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
|
||||
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
|
||||
if (range === 'all' && !data.stats.earliest) from.setUTCFullYear(from.getUTCFullYear() - 1);
|
||||
if (range === 'all' && !data.stats.earliest_day) from.setUTCFullYear(from.getUTCFullYear() - 1);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
function bucketLabel(seconds: number) {
|
||||
if (seconds >= 7 * 86400) return 'weekly';
|
||||
if (seconds >= 86400) return 'daily';
|
||||
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
|
||||
}
|
||||
|
||||
@@ -256,19 +319,73 @@
|
||||
<p class="eyebrow">market archive / counter-strike</p>
|
||||
<h1>CS prices</h1>
|
||||
<p class="lede">
|
||||
Explore price histories for individual listings or compare groups of items over time.
|
||||
Every CS2 skin, knife, glove, sticker, case and more, in every wear, StatTrak, Souvenir and
|
||||
Doppler phase. Prices from Skinport, Steam, CSFloat and Buff163 are archived daily.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div><strong>{number.format(data.stats.items)}</strong><span>listings indexed</span></div>
|
||||
<div><strong>{number.format(data.stats.skins)}</strong><span>items</span></div>
|
||||
<div><strong>{number.format(data.stats.configs)}</strong><span>configs</span></div>
|
||||
<div>
|
||||
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
|
||||
>stored observations</span
|
||||
<strong>{number.format(data.stats.latest_rows ?? 0)}</strong><span
|
||||
>prices {data.stats.latest_day ?? ''}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="browse" aria-label="Browse items">
|
||||
<div class="browse-bar">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={browseQuery}
|
||||
placeholder="Search AK-47 Redline, Karambit Doppler, Katowice 2014…"
|
||||
/>
|
||||
<NativeSelect bind:value={browseSort}
|
||||
><option value="popular">Most listed</option><option value="price">Most expensive</option
|
||||
><option value="name">Name</option></NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div class="kinds" role="group" aria-label="Type">
|
||||
<button class:on={!browseKind} onclick={() => (browseKind = '')}>All</button>
|
||||
{#each data.kinds as k (k.value)}<button
|
||||
class:on={browseKind === k.value}
|
||||
onclick={() => (browseKind = browseKind === k.value ? '' : k.value)}>{k.label}</button
|
||||
>{/each}
|
||||
</div>
|
||||
<div class="hits" class:dim={browsing}>
|
||||
{#each hits as hit (hit.slug)}
|
||||
<a
|
||||
class="hit"
|
||||
href={`/projects/cs-prices/${hit.slug}`}
|
||||
style:--rarity={hit.rarity_color ?? 'transparent'}
|
||||
>
|
||||
{#if hit.image}<img
|
||||
src={hit.image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="96"
|
||||
height="72"
|
||||
/>{:else}<span class="noimg"></span>{/if}
|
||||
<span class="hit-name">{hit.name}</span>
|
||||
<span class="hit-meta"
|
||||
>{hit.from_cents != null ? `from ${euro.format(hit.from_cents / 100)}` : 'no listings'} ·
|
||||
{hit.configs}
|
||||
config{hit.configs === 1 ? '' : 's'}</span
|
||||
>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="none">{browsing ? 'Searching…' : 'Nothing matches.'}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{#if moreLeft && hits.length >= 60}<div class="more">
|
||||
<Button variant="outline" size="sm" onclick={loadMore} disabled={browsing}>Show more</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">Compare over time</h2>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="picker" aria-label="Series selection">
|
||||
<div class="panel-heading">
|
||||
@@ -357,11 +474,10 @@
|
||||
></label
|
||||
><label
|
||||
>Range<NativeSelect bind:value={range}
|
||||
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
|
||||
value="7d">7 days</option
|
||||
><option value="30d">30 days</option><option value="1y">1 year</option><option
|
||||
value="5y">5 years</option
|
||||
><option value="all">All history</option><option value="custom">Custom</option
|
||||
><option value="7d">7 days</option><option value="30d">30 days</option><option
|
||||
value="1y">1 year</option
|
||||
><option value="5y">5 years</option><option value="all">All history</option><option
|
||||
value="custom">Custom</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
@@ -394,9 +510,9 @@
|
||||
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
|
||||
'-',
|
||||
' '
|
||||
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
|
||||
Group lines average each listing equally within a bucket; raw observations remain in the
|
||||
database.
|
||||
)} · {bucketSeconds ? `${bucketLabel(bucketSeconds)} points` : 'choose a series'}. Group
|
||||
lines average each config equally per point. Open an item above for its own page with every
|
||||
config.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -436,6 +552,95 @@
|
||||
h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.browse {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.browse-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 170px;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.kinds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.kinds button {
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
color: var(--site-muted);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.kinds button:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.kinds button.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.hits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
|
||||
gap: 0.5rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.hits.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.hit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.6rem 0.7rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-bottom: 2px solid var(--rarity);
|
||||
background: var(--site-surface);
|
||||
border-radius: 8px;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.hit:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--site-text);
|
||||
border-bottom-color: var(--rarity);
|
||||
}
|
||||
.hit img,
|
||||
.noimg {
|
||||
width: 100%;
|
||||
height: 82px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.hit-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.hit-meta {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.none {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--site-muted);
|
||||
max-width: 40rem;
|
||||
@@ -637,6 +842,15 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.browse-bar {
|
||||
grid-template-columns: 1fr 130px;
|
||||
}
|
||||
.hits {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.stats {
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.intro {
|
||||
display: block;
|
||||
}
|
||||
|
||||
23
src/routes/projects/cs-prices/[slug]/+page.server.ts
Normal file
23
src/routes/projects/cs-prices/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { loadSeries, loadSkin, pickConfig, streams } from '#lib/server/cs-prices.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// One skin (or sticker, case, agent, …) with every market config it has: wear, StatTrak,
|
||||
// Souvenir and Doppler phase. The selected config comes from ?v=&wear=&phase= so every config
|
||||
// has its own URL; its full daily history (EUR) is loaded here and the range is picked client-side.
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const found = await loadSkin(params.slug);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const config = pickConfig(found.configs, url.searchParams);
|
||||
const [streamList, history] = await Promise.all([
|
||||
streams(),
|
||||
config ? loadSeries(config, null) : Promise.resolve(null)
|
||||
]);
|
||||
return {
|
||||
skin: found.skin,
|
||||
configs: found.configs,
|
||||
selected: config?.id ?? null,
|
||||
streams: streamList,
|
||||
history
|
||||
};
|
||||
};
|
||||
653
src/routes/projects/cs-prices/[slug]/+page.svelte
Normal file
653
src/routes/projects/cs-prices/[slug]/+page.svelte
Normal file
@@ -0,0 +1,653 @@
|
||||
<script lang="ts">
|
||||
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
|
||||
import { phaseOrder, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
type Config = (typeof data.configs)[number];
|
||||
type Variant = 'normal' | 'stattrak' | 'souvenir';
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
'skinport:lowest-listing': '#3b82f6',
|
||||
'skinport:sale-median-24h': '#22c55e',
|
||||
'steam-market:sale-avg': '#a855f7',
|
||||
'steam-market:lowest-listing': '#d946ef',
|
||||
'csfloat:lowest-listing': '#f59e0b',
|
||||
'buff163:lowest-listing': '#ef4444',
|
||||
'steam-market:sale-median-history': '#14b8a6',
|
||||
'csfloat:sale-avg-history': '#a3e635'
|
||||
};
|
||||
const sourceNames: Record<string, string> = {
|
||||
skinport: 'Skinport',
|
||||
'steam-market': 'Steam',
|
||||
csfloat: 'CSFloat',
|
||||
buff163: 'Buff163'
|
||||
};
|
||||
const kindNames: Record<string, string> = {
|
||||
'lowest-listing': 'lowest listing',
|
||||
'sale-median-24h': 'sold, 24h median',
|
||||
'sale-avg': 'sold, average',
|
||||
'sale-median-history': 'sold, daily median (history)',
|
||||
'sale-avg-history': 'sold, daily average (history)'
|
||||
};
|
||||
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
||||
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
|
||||
const count = new Intl.NumberFormat('nl-NL');
|
||||
const money = (cents: number | null | undefined) =>
|
||||
cents == null ? '–' : eur.format(cents / 100);
|
||||
|
||||
let streamById = $derived(new Map(data.streams.map((s) => [s.id, s])));
|
||||
let selected = $derived(data.configs.find((c) => c.id === data.selected) ?? null);
|
||||
const variantOf = (c: { stattrak: boolean; souvenir: boolean }): Variant =>
|
||||
c.souvenir ? 'souvenir' : c.stattrak ? 'stattrak' : 'normal';
|
||||
let variants = $derived(
|
||||
(['normal', 'stattrak', 'souvenir'] as Variant[]).filter((v) =>
|
||||
data.configs.some((c) => variantOf(c) === v)
|
||||
)
|
||||
);
|
||||
let currentVariant = $derived(selected ? variantOf(selected) : 'normal');
|
||||
let wears = $derived(
|
||||
wearOrder.filter((w) =>
|
||||
data.configs.some((c) => c.wear === w && variantOf(c) === currentVariant)
|
||||
)
|
||||
);
|
||||
let phases = $derived(
|
||||
phaseOrder.filter((p) =>
|
||||
data.configs.some(
|
||||
(c) =>
|
||||
c.phase === p && variantOf(c) === currentVariant && c.wear === (selected?.wear ?? null)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/** Best price to show for a config: lowest EUR listing, else the latest sale. */
|
||||
function best(config: Config) {
|
||||
const listings = config.prices.filter(
|
||||
(p) => streamById.get(p.stream)?.kind === 'lowest-listing'
|
||||
);
|
||||
const pool = listings.length ? listings : config.prices;
|
||||
return pool.length ? Math.min(...pool.map((p) => p.eur_cents)) : null;
|
||||
}
|
||||
function find(v: Variant, wear: string | null, phase: string | null) {
|
||||
return data.configs.find((c) => variantOf(c) === v && c.wear === wear && c.phase === phase);
|
||||
}
|
||||
function href(config: Config | undefined | null) {
|
||||
if (!config) return '?';
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', variantOf(config));
|
||||
if (config.wear) params.set('wear', wearShort[config.wear]);
|
||||
if (config.phase) params.set('phase', config.phase);
|
||||
return `?${params}`;
|
||||
}
|
||||
function forVariant(v: Variant) {
|
||||
return (
|
||||
find(v, selected?.wear ?? null, selected?.phase ?? null) ??
|
||||
find(v, selected?.wear ?? null, null) ??
|
||||
data.configs.find((c) => variantOf(c) === v && c.prices.length) ??
|
||||
data.configs.find((c) => variantOf(c) === v)
|
||||
);
|
||||
}
|
||||
function forWear(w: string) {
|
||||
return find(currentVariant, w, selected?.phase ?? null) ?? find(currentVariant, w, null);
|
||||
}
|
||||
function configLabel(c: Config) {
|
||||
const parts = [];
|
||||
if (c.souvenir) parts.push('Souvenir');
|
||||
if (c.stattrak) parts.push('StatTrak™');
|
||||
if (c.wear) parts.push(c.wear);
|
||||
if (c.phase) parts.push(c.phase);
|
||||
return parts.join(' · ') || 'Standard';
|
||||
}
|
||||
function streamLabel(id: number, anyPhase = false) {
|
||||
const s = streamById.get(id);
|
||||
if (!s) return `Stream ${id}`;
|
||||
return `${sourceNames[s.source] ?? s.source} ${kindNames[s.kind] ?? s.kind.replaceAll('-', ' ')}${anyPhase ? ' (any phase)' : ''}`;
|
||||
}
|
||||
|
||||
let range = $state('all');
|
||||
let cutoff = $derived.by(() => {
|
||||
if (range === 'all') return 0;
|
||||
const days = { '30d': 30, '90d': 90, '1y': 365 }[range] ?? 0;
|
||||
return Date.now() - days * 86_400_000;
|
||||
});
|
||||
let series = $derived<PriceSeries[]>(
|
||||
(data.history?.series ?? [])
|
||||
.map((s) => {
|
||||
const stream = streamById.get(s.stream);
|
||||
return {
|
||||
key: `${s.stream}:${s.anyPhase}`,
|
||||
name: streamLabel(s.stream, s.anyPhase),
|
||||
color: stream ? colors[`${stream.source}:${stream.kind}`] : undefined,
|
||||
dashed: s.anyPhase,
|
||||
points: s.points
|
||||
.map(([day, cents]) => [Date.parse(day), cents] as [number, number])
|
||||
.filter(([t]) => t >= cutoff)
|
||||
};
|
||||
})
|
||||
.filter((s) => s.points.length)
|
||||
);
|
||||
let days = $derived(new Set(series.flatMap((s) => s.points.map(([t]) => t))).size);
|
||||
let current = $derived(
|
||||
(selected?.prices ?? [])
|
||||
.map((p) => ({ ...p, stream: streamById.get(p.stream), id: p.stream }))
|
||||
.sort((a, b) => a.eur_cents - b.eur_cents)
|
||||
);
|
||||
const spanOrder = ['24h', '7d', '30d', '90d'];
|
||||
let windows = $derived(
|
||||
[...(data.history?.windows ?? [])].sort(
|
||||
(a, b) =>
|
||||
a.source.localeCompare(b.source) || spanOrder.indexOf(a.span) - spanOrder.indexOf(b.span)
|
||||
)
|
||||
);
|
||||
let windowSources = $derived([...new Set(windows.map((w) => w.source))]);
|
||||
const fxCents = (cents: number | null, currency: string) =>
|
||||
cents == null
|
||||
? null
|
||||
: currency === 'USD'
|
||||
? Math.round(cents * (data.history?.fx?.rate ?? 0.92))
|
||||
: cents;
|
||||
let title = $derived(
|
||||
selected && selected.market_hash_name !== data.skin.name
|
||||
? `${selected.market_hash_name}${selected.phase ? ` (${selected.phase})` : ''}`
|
||||
: data.skin.name
|
||||
);
|
||||
let image = $derived(selected?.icon_url ?? data.skin.image);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · CS prices · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={`Daily ${data.skin.name} prices for every wear, StatTrak, Souvenir and phase, from Skinport, Steam, CSFloat and Buff163.`}
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="cs-item">
|
||||
<a class="back" href="/projects/cs-prices">← CS prices</a>
|
||||
<header class="head">
|
||||
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
|
||||
<div class="title">
|
||||
<p class="eyebrow">
|
||||
<span class="rarity" style:--rarity={data.skin.rarity_color ?? 'var(--site-muted)'}></span>
|
||||
{[data.skin.rarity, data.skin.category ?? data.skin.kind].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
<h1>{data.skin.name}</h1>
|
||||
<p class="meta">
|
||||
{#if data.skin.min_float != null}Float {data.skin.min_float.toFixed(
|
||||
2
|
||||
)}–{data.skin.max_float?.toFixed(2)} ·
|
||||
{/if}{data.configs.length} config{data.configs.length === 1 ? '' : 's'}
|
||||
{#if data.skin.collections?.length}
|
||||
· {data.skin.collections.join(', ')}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if selected}<div class="price">
|
||||
<strong>{money(best(selected))}</strong><span>{configLabel(selected)}</span>
|
||||
</div>{/if}
|
||||
</header>
|
||||
|
||||
{#if data.configs.length > 1}
|
||||
<nav class="configs" aria-label="Config">
|
||||
{#if variants.length > 1}
|
||||
<div class="row">
|
||||
{#each variants as v (v)}
|
||||
{@const target = forVariant(v)}
|
||||
<a
|
||||
class="chip"
|
||||
class:on={currentVariant === v}
|
||||
class:st={v === 'stattrak'}
|
||||
class:sv={v === 'souvenir'}
|
||||
href={href(target)}
|
||||
data-sveltekit-reset="false"
|
||||
data-sveltekit-replacestate
|
||||
>{v === 'normal' ? 'Normal' : v === 'stattrak' ? 'StatTrak™' : 'Souvenir'}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if wears.length}
|
||||
<div class="row">
|
||||
{#each wears as w (w)}
|
||||
{@const target = forWear(w)}
|
||||
<a
|
||||
class="chip wear"
|
||||
class:on={selected?.wear === w}
|
||||
href={href(target)}
|
||||
data-sveltekit-reset="false"
|
||||
data-sveltekit-replacestate
|
||||
title={w}><b>{wearShort[w]}</b><small>{target ? money(best(target)) : '–'}</small></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if phases.length}
|
||||
{@const any = find(currentVariant, selected?.wear ?? null, null)}
|
||||
<div class="row">
|
||||
{#if any}<a
|
||||
class="chip"
|
||||
class:on={!selected?.phase}
|
||||
href={href(any)}
|
||||
data-sveltekit-reset="false"
|
||||
data-sveltekit-replacestate>Any phase</a
|
||||
>{/if}
|
||||
{#each phases as p (p)}
|
||||
{@const target = find(currentVariant, selected?.wear ?? null, p)}
|
||||
<a
|
||||
class="chip phase"
|
||||
class:on={selected?.phase === p}
|
||||
data-phase={p}
|
||||
href={href(target)}
|
||||
data-sveltekit-reset="false"
|
||||
data-sveltekit-replacestate
|
||||
><b>{p.replace('Phase ', 'P')}</b><small>{target ? money(best(target)) : '–'}</small
|
||||
></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
{#if current.length}
|
||||
<section class="now" aria-label="Latest prices">
|
||||
{#each current as p (p.id)}
|
||||
<div
|
||||
class="tile"
|
||||
style:--accent={p.stream ? colors[`${p.stream.source}:${p.stream.kind}`] : undefined}
|
||||
>
|
||||
<span>{streamLabel(p.id)}</span>
|
||||
<strong>{money(p.eur_cents)}</strong>
|
||||
<small
|
||||
>{p.stream?.currency === 'USD' ? `${usd.format(p.cents / 100)} · ` : ''}{p.volume !=
|
||||
null
|
||||
? `${count.format(p.volume)} ${p.stream?.kind === 'lowest-listing' ? 'listed' : 'sold'} · `
|
||||
: ''}{p.day}</small
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card chart-card" aria-label="Price history">
|
||||
<div class="toolbar">
|
||||
<h2>Price history <small>EUR · daily</small></h2>
|
||||
<div class="ranges" role="group" aria-label="Range">
|
||||
{#each ['30d', '90d', '1y', 'all'] as r (r)}
|
||||
<button class:on={range === r} onclick={() => (range = r)}
|
||||
>{r === 'all' ? 'All' : r}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if series.length}
|
||||
<PriceChart {series} kind="daily" currency="EUR" height="min(52vh, 420px)" zoom={days > 30} />
|
||||
<p class="note">
|
||||
{days} day{days === 1 ? '' : 's'} of history · USD sources at the ECB rate of each day{data
|
||||
.history?.fx
|
||||
? ` (now ${data.history.fx.rate.toFixed(4)} €/$)`
|
||||
: ''} · dashed = price for any phase
|
||||
</p>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<strong>No prices yet for this config</strong>
|
||||
<p>Prices are collected daily for every config; untraded ones stay empty.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if windows.length}
|
||||
<section class="card" aria-label="Recent sales">
|
||||
<h2>Recent sales</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Source</th>{#each spanOrder as s (s)}<th>{s}</th>{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each windowSources as source (source)}
|
||||
<tr>
|
||||
<th
|
||||
>{sourceNames[source] ?? source}{windows.find((w) => w.source === source)
|
||||
?.anyPhase
|
||||
? ' (any phase)'
|
||||
: ''}</th
|
||||
>
|
||||
{#each spanOrder as span (span)}
|
||||
{@const w = windows.find((x) => x.source === source && x.span === span)}
|
||||
<td>
|
||||
{#if w}
|
||||
{money(fxCents(w.median_cents ?? w.avg_cents, w.currency))}
|
||||
{#if w.volume}<small>{count.format(w.volume)} sold</small>{/if}
|
||||
{:else}–{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="note">Skinport: median sale price and sales count. Steam: average sale price.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.configs.length > 1}
|
||||
<section class="card" aria-label="All configs">
|
||||
<h2>All configs</h2>
|
||||
<div class="all">
|
||||
{#each data.configs as c (c.id)}
|
||||
<a
|
||||
class="cfg"
|
||||
class:on={c.id === data.selected}
|
||||
href={href(c)}
|
||||
data-sveltekit-reset="false"
|
||||
data-sveltekit-replacestate><span>{configLabel(c)}</span><b>{money(best(c))}</b></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cs-item {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(0.9rem, 4vw, 2.5rem) 4rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.back {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
margin: 1rem 0 1.2rem;
|
||||
}
|
||||
.head img {
|
||||
width: 128px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
flex: none;
|
||||
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--site-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rarity {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--rarity);
|
||||
box-shadow: 0 0 10px var(--rarity);
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(1.6rem, 4.5vw, 2.6rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.05em;
|
||||
margin: 0.35rem 0 0.4rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
h2 small {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
.meta {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.price {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.price strong {
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.price span {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.configs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.chip small {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--site-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.chip.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.chip.on small {
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.chip.st:not(.on) {
|
||||
color: #f97316;
|
||||
}
|
||||
.chip.sv:not(.on) {
|
||||
color: #eab308;
|
||||
}
|
||||
.chip.phase[data-phase='Ruby'] b {
|
||||
color: #ef4444;
|
||||
}
|
||||
.chip.phase[data-phase='Sapphire'] b {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.chip.phase[data-phase='Black Pearl'] b {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.chip.phase[data-phase='Emerald'] b {
|
||||
color: #22c55e;
|
||||
}
|
||||
.chip.on.phase b {
|
||||
color: inherit !important;
|
||||
}
|
||||
.now {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tile {
|
||||
border: 1px solid var(--site-border);
|
||||
border-left: 3px solid var(--accent, var(--site-border));
|
||||
background: var(--site-surface);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.tile span,
|
||||
.tile small {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.tile strong {
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ranges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.ranges button {
|
||||
border: 1px solid var(--site-border);
|
||||
background: transparent;
|
||||
color: var(--site-muted);
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ranges button:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.ranges button.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.note {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.empty {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
thead th {
|
||||
color: var(--site-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
td small {
|
||||
display: block;
|
||||
color: var(--site-muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.all {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.cfg {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cfg:hover {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.cfg.on {
|
||||
background: var(--site-hover);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.cfg span {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head img {
|
||||
width: 96px;
|
||||
}
|
||||
.price {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.now {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,12 +5,14 @@ import type { RequestHandler } from './$types';
|
||||
const maxSeries = 1000;
|
||||
const maxDays = 365 * 20;
|
||||
|
||||
// Compare explorer: daily prices (cs.daily_prices) for up to 1000 configs or groups, bucketed per
|
||||
// day, or per week for ranges over 400 days. Group lines average each config equally per bucket.
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
|
||||
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
|
||||
const source = typeof body.source === 'string' ? body.source : 'steam-market';
|
||||
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
|
||||
const kind = typeof body.kind === 'string' ? body.kind : 'lowest-listing';
|
||||
const source = typeof body.source === 'string' ? body.source : 'skinport';
|
||||
const currency = typeof body.currency === 'string' ? body.currency : 'EUR';
|
||||
const ids = Array.isArray(body.ids) ? body.ids : [];
|
||||
const values = Array.isArray(body.values) ? body.values : [];
|
||||
if (
|
||||
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
)
|
||||
return Response.json({ error: 'Invalid selection' }, { status: 400 });
|
||||
if (dimension === 'item' ? !ids.length : !values.length)
|
||||
return Response.json({ series: [], bucketSeconds: 0 });
|
||||
return Response.json({ points: [], bucketSeconds: 0 });
|
||||
const to = new Date(typeof body.to === 'string' ? body.to : Date.now());
|
||||
const from = new Date(typeof body.from === 'string' ? body.from : to.getTime() - 365 * 86400_000);
|
||||
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
|
||||
return Response.json({ error: 'Invalid date range' }, { status: 400 });
|
||||
// Aim for at most 240 buckets per series. Raw observations remain untouched.
|
||||
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
||||
const weekly = +to - +from > 400 * 86400_000;
|
||||
const seconds = weekly ? 7 * 86400 : 86400;
|
||||
const selection =
|
||||
dimension === 'item'
|
||||
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
|
||||
@@ -44,22 +46,22 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
sql`,`
|
||||
)})`;
|
||||
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
|
||||
const bucket = weekly ? sql`date_trunc('week', p.day)::date` : sql`p.day`;
|
||||
const rows = await db.execute(sql`
|
||||
WITH per_item AS (
|
||||
SELECT ${seriesKey} AS series_key, p.item_id,
|
||||
date_bin(make_interval(secs => ${seconds}), p.observed_at,
|
||||
TIMESTAMPTZ '1970-01-01') AS bucket,
|
||||
avg(p.price_cents) AS price_cents,
|
||||
sum(p.volume) AS volume,
|
||||
min(p.price_cents) AS min_cents,
|
||||
max(p.price_cents) AS max_cents
|
||||
FROM cs.price_observations p ${selection}
|
||||
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
|
||||
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
||||
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
||||
WITH stream AS (
|
||||
SELECT id FROM cs.price_streams
|
||||
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}
|
||||
), per_item AS (
|
||||
SELECT ${seriesKey} AS series_key, p.item_id, ${bucket} AS bucket,
|
||||
avg(p.price_cents) AS price_cents, sum(p.volume) AS volume,
|
||||
min(p.price_cents) AS min_cents, max(p.price_cents) AS max_cents
|
||||
FROM cs.daily_prices p ${selection}
|
||||
WHERE p.stream_id = (SELECT id FROM stream)
|
||||
AND p.day >= ${from.toISOString().slice(0, 10)}::date
|
||||
AND p.day <= ${to.toISOString().slice(0, 10)}::date
|
||||
GROUP BY 1, 2, 3
|
||||
)
|
||||
SELECT series_key, bucket, round(avg(price_cents))::int AS price_cents,
|
||||
SELECT series_key, bucket::text AS bucket, round(avg(price_cents))::int AS price_cents,
|
||||
count(*)::int AS items, sum(volume)::bigint AS volume,
|
||||
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
|
||||
FROM per_item GROUP BY 1, 2 ORDER BY 1, 2`);
|
||||
|
||||
13
src/routes/projects/cs-prices/api/skins/+server.ts
Normal file
13
src/routes/projects/cs-prices/api/skins/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { searchSkins } from '#lib/server/cs-prices.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const skins = await searchSkins(
|
||||
url.searchParams.get('q') ?? '',
|
||||
url.searchParams.get('kind') ?? '',
|
||||
url.searchParams.get('sort') ?? 'popular',
|
||||
Number(url.searchParams.get('limit') ?? 60) || 60,
|
||||
Number(url.searchParams.get('offset') ?? 0) || 0
|
||||
);
|
||||
return Response.json({ skins });
|
||||
};
|
||||
@@ -18,18 +18,20 @@ export const load: PageServerLoad = async ({ url, parent }) => {
|
||||
const status = STATUSES.includes(first('status')) ? first('status') : 'todo';
|
||||
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
|
||||
const q = first('q').slice(0, 100);
|
||||
const view = first('view') === 'map' ? 'map' : 'list';
|
||||
const filter = { scale, category, status, q };
|
||||
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
|
||||
return {
|
||||
filter,
|
||||
sort,
|
||||
view,
|
||||
tasks: list.tasks,
|
||||
counts: totals,
|
||||
seo: {
|
||||
title: 'The worldwide todo list',
|
||||
description:
|
||||
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.',
|
||||
index: !scale && !category && status === 'todo' && !q && sort === 'updated'
|
||||
index: !scale && !category && status === 'todo' && !q && sort === 'updated' && view === 'list'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
|
||||
import WorldMap from '#lib/worldtodo/WorldMap.svelte';
|
||||
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
|
||||
import IconTile from '#lib/worldtodo/IconTile.svelte';
|
||||
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
||||
import { subscribe } from '#lib/worldtodo/live';
|
||||
@@ -12,6 +14,9 @@
|
||||
type PublicTask
|
||||
} from '#lib/worldtodo/shared';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import List from 'lucide-svelte/icons/list';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import Earth from 'lucide-svelte/icons/earth';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -90,6 +95,31 @@
|
||||
};
|
||||
|
||||
const f = $derived(data.filter);
|
||||
const isMap = $derived(data.view === 'map');
|
||||
// List / Map toggle keeps every filter in the URL (?view=map is shareable).
|
||||
const viewHref = (view: 'list' | 'map') => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const p = new URLSearchParams();
|
||||
if (f.scale) p.set('scale', f.scale);
|
||||
if (f.category) p.set('category', f.category);
|
||||
if (f.status !== 'todo') p.set('status', f.status);
|
||||
if (f.q) p.set('q', f.q);
|
||||
if (data.sort !== 'updated') p.set('sort', data.sort);
|
||||
if (view === 'map') p.set('view', 'map');
|
||||
const s = p.toString();
|
||||
return `/projects/worldwide-todo${s ? `?${s}` : ''}`;
|
||||
};
|
||||
const pinnable = (t: PublicTask) => t.locationPrecision !== 'none';
|
||||
const globalTasks = $derived(tasks.filter((t) => !pinnable(t)));
|
||||
const mapListed = $derived(tasks.filter(pinnable));
|
||||
const fitKey = $derived(JSON.stringify(data.filter));
|
||||
let mapRef = $state<ReturnType<typeof WorldMap>>();
|
||||
let hovered = $state<string | null>(null);
|
||||
function cardClick(e: MouseEvent, task: PublicTask) {
|
||||
if (!isMap || !mapRef || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return;
|
||||
e.preventDefault();
|
||||
mapRef.focusTask(task.slug);
|
||||
}
|
||||
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
|
||||
</script>
|
||||
|
||||
@@ -185,16 +215,75 @@
|
||||
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
|
||||
{#if f.category}<input type="hidden" name="category" value={f.category} />{/if}
|
||||
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/if}
|
||||
{#if isMap}<input type="hidden" name="view" value="map" />{/if}
|
||||
</form>
|
||||
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
|
||||
{/if}
|
||||
</p>
|
||||
<div class="results-bar">
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if isMap}· {mapListed.length} on the map{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <a
|
||||
href={isMap ? '/projects/worldwide-todo?view=map' : '/projects/worldwide-todo'}
|
||||
data-sveltekit-noscroll>clear filters</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
<nav class="views" aria-label="View">
|
||||
<a
|
||||
href={viewHref('list')}
|
||||
class={{ on: !isMap }}
|
||||
aria-current={!isMap ? 'page' : undefined}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-keepfocus><List size={15} aria-hidden="true" />List</a
|
||||
>
|
||||
<a
|
||||
href={viewHref('map')}
|
||||
class={{ on: isMap }}
|
||||
aria-current={isMap ? 'page' : undefined}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-keepfocus><MapIcon size={15} aria-hidden="true" />Map</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<p class="sr-only" aria-live="polite">{announce}</p>
|
||||
|
||||
{#if isMap}
|
||||
{#if globalTasks.length}
|
||||
<section class="global" aria-label="Global tasks (no single place on the map)">
|
||||
<h2><Earth size={15} aria-hidden="true" />Global <span>{globalTasks.length}</span></h2>
|
||||
<ul>
|
||||
{#each globalTasks as task (task.slug)}
|
||||
{@const cat = catBySlug.get(task.category)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/worldwide-todo/{task.slug}"
|
||||
class={{ flash: flashing === task.slug }}
|
||||
>
|
||||
<span class="g-top">
|
||||
<span class="badge">{task.scale === 'planet' ? 'Global' : 'No pin'}</span>
|
||||
<span class="g-cat"
|
||||
><i style="background:{cat?.colour ?? '#737373'}"></i>{cat?.label ??
|
||||
task.category}</span
|
||||
>
|
||||
<StatusPill status={task.status} />
|
||||
</span>
|
||||
<span class="g-title">{task.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
<WorldMap
|
||||
bind:this={mapRef}
|
||||
tasks={mapListed}
|
||||
categories={data.categories}
|
||||
highlight={hovered}
|
||||
{fitKey}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ul class="grid">
|
||||
<li>
|
||||
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
|
||||
@@ -203,8 +292,14 @@
|
||||
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
|
||||
</a>
|
||||
</li>
|
||||
{#each tasks as task (task.slug)}
|
||||
<li>
|
||||
{#each isMap ? mapListed : tasks as task (task.slug)}
|
||||
<li
|
||||
onmouseenter={() => (hovered = task.slug)}
|
||||
onmouseleave={() => (hovered = hovered === task.slug ? null : hovered)}
|
||||
onfocusin={() => (hovered = task.slug)}
|
||||
onfocusout={() => (hovered = hovered === task.slug ? null : hovered)}
|
||||
onclickcapture={(e) => cardClick(e, task)}
|
||||
>
|
||||
<TaskCard
|
||||
{task}
|
||||
category={catBySlug.get(task.category)}
|
||||
@@ -379,6 +474,133 @@
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.results-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.views {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.views a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 550;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.views a:hover,
|
||||
.views a:focus-visible {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.views a.on {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
box-shadow: inset 0 0 0 1px var(--site-text);
|
||||
}
|
||||
.global h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.global h2 :global(svg) {
|
||||
color: #38bdf8;
|
||||
}
|
||||
.global h2 span {
|
||||
color: var(--site-muted);
|
||||
font-weight: 450;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.global ul {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(15rem, 18rem);
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.global li {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.global a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
background-color 0.6s;
|
||||
}
|
||||
.global a:hover,
|
||||
.global a:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.global a.flash {
|
||||
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
|
||||
}
|
||||
.g-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.badge {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #38bdf8, #16a34a);
|
||||
}
|
||||
.g-cat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.g-cat i {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.g-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 550;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.results a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { listTasks } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET /projects/worldwide-todo/api/tasks.geojson?scale=&category=&status=&q=
|
||||
// Every task with a map position (precision exact or approximate) as a GeoJSON
|
||||
// FeatureCollection, same filters as /api/tasks. Planet tasks and tasks without coordinates
|
||||
// are left out (precision 'none').
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const p = (k: string) => url.searchParams.get(k)?.trim() ?? '';
|
||||
const scale = ['planet', 'region', 'country', 'city', 'street'].includes(p('scale'))
|
||||
? p('scale')
|
||||
: '';
|
||||
const status = ['todo', 'open', 'in_progress', 'done', 'all'].includes(p('status'))
|
||||
? p('status')
|
||||
: 'todo';
|
||||
const category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
|
||||
const { tasks } = await listTasks(
|
||||
{ scale, category, status, q: p('q').slice(0, 100) },
|
||||
'updated',
|
||||
1000
|
||||
);
|
||||
const features = tasks
|
||||
.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
|
||||
.map((t) => ({
|
||||
type: 'Feature' as const,
|
||||
id: t.id,
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon!, t.lat!] },
|
||||
properties: {
|
||||
slug: t.slug,
|
||||
title: t.title,
|
||||
scale: t.scale,
|
||||
category: t.category,
|
||||
status: t.status,
|
||||
location_name: t.locationName,
|
||||
location_precision: t.locationPrecision,
|
||||
url: `${url.origin}/projects/worldwide-todo/${t.slug}`
|
||||
}
|
||||
}));
|
||||
return new Response(JSON.stringify({ type: 'FeatureCollection', features }), {
|
||||
headers: {
|
||||
'content-type': 'application/geo+json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=30'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -60,6 +60,16 @@
|
||||
new: formatValue(k, change[k])
|
||||
}));
|
||||
}
|
||||
/** Coordinates the request proposes (picked on the map or typed), to check before approving. */
|
||||
function proposedPosition(item: Item) {
|
||||
const p = item.payload as Record<string, unknown>;
|
||||
if (item.kind === 'delete' || !('lat' in p || 'lon' in p)) return null;
|
||||
const lat = Number(p.lat ?? item.task?.lat);
|
||||
const lon = Number(p.lon ?? item.task?.lon);
|
||||
return p.lat !== null && p.lon !== null && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? { lat, lon }
|
||||
: null;
|
||||
}
|
||||
function tweakValues(item: Item) {
|
||||
const base = item.task ? fieldsOf(item.task) : {};
|
||||
return { ...base, ...item.payload } as Record<string, unknown>;
|
||||
@@ -194,6 +204,15 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{@const pos = proposedPosition(item)}
|
||||
{#if pos}
|
||||
<a
|
||||
class="osm"
|
||||
href="https://www.openstreetmap.org/?mlat={pos.lat}&mlon={pos.lon}#map=15/{pos.lat}/{pos.lon}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Check the proposed map position on OpenStreetMap ↗</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<dl class="meta">
|
||||
@@ -573,4 +592,10 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.osm {
|
||||
display: inline-block;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
|
||||
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
|
||||
// /trackers/ai: the news tab is public; bots, usage and setup are owner-only (their own loads call
|
||||
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
const owner = isOwner(event);
|
||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Newspaper from 'lucide-svelte/icons/newspaper';
|
||||
import Bot from 'lucide-svelte/icons/bot';
|
||||
import Gauge from 'lucide-svelte/icons/gauge';
|
||||
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
@@ -14,7 +15,8 @@
|
||||
...(data.owner
|
||||
? [
|
||||
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
|
||||
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
|
||||
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true },
|
||||
{ name: 'setup', href: '/trackers/ai/setup', icon: SlidersHorizontal, locked: true }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
@@ -25,7 +27,10 @@
|
||||
<TrackerIcon name="ai" class="size-10" />
|
||||
<div>
|
||||
<h1>ai</h1>
|
||||
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
|
||||
<p>
|
||||
AI and software news, plus what my bots are doing, how much I use each model and how each
|
||||
one is set up.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="ai-tabs" aria-label="AI tracker sections">
|
||||
@@ -118,4 +123,7 @@
|
||||
.tab-usage {
|
||||
background: linear-gradient(135deg, #34d399, #0d9488);
|
||||
}
|
||||
.tab-setup {
|
||||
background: linear-gradient(135deg, #a78bfa, #6d28d9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
|
||||
// header_already_set (500) for the owner.
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {
|
||||
bots: await loadBots(),
|
||||
now: Date.now(),
|
||||
|
||||
45
src/routes/trackers/ai/setup/+page.server.ts
Normal file
45
src/routes/trackers/ai/setup/+page.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import {
|
||||
addEntry,
|
||||
addProvider,
|
||||
deleteEntry,
|
||||
deleteProvider,
|
||||
loadSetup,
|
||||
updateEntry,
|
||||
updateProvider
|
||||
} from '#lib/server/ai-setup.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad, RequestEvent } from './$types';
|
||||
|
||||
// Owner only: how each AI provider is set up (connections, skills, instructions, ...).
|
||||
// Actions don't run the load, so each one checks the owner again (hooks.server.ts also 404s
|
||||
// this path for everyone else).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// No setHeaders here: the ai layout already sets cache-control for the owner (setting it
|
||||
// twice throws), and hooks.server.ts adds private, no-store on owner-only paths.
|
||||
requireOwner(event);
|
||||
return {
|
||||
providers: await loadSetup(),
|
||||
now: Date.now(),
|
||||
seo: { title: 'AI tracker · setup', description: 'How my AI tools are set up.', index: false }
|
||||
};
|
||||
};
|
||||
|
||||
type Handler = (form: FormData) => Promise<{ ok: true } | { ok: false; message: string }>;
|
||||
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const r = await fn(form);
|
||||
return r.ok
|
||||
? { ok: true as const }
|
||||
: fail(400, { message: r.message, scope: String(form.get('form_key') ?? '') });
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addProvider: action(addProvider),
|
||||
updateProvider: action(updateProvider),
|
||||
deleteProvider: action(deleteProvider),
|
||||
addEntry: action(addEntry),
|
||||
updateEntry: action(updateEntry),
|
||||
deleteEntry: action(deleteEntry)
|
||||
};
|
||||
834
src/routes/trackers/ai/setup/+page.svelte
Normal file
834
src/routes/trackers/ai/setup/+page.svelte
Normal file
@@ -0,0 +1,834 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import Plug from 'lucide-svelte/icons/plug';
|
||||
import WandSparkles from 'lucide-svelte/icons/wand-sparkles';
|
||||
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||
import Puzzle from 'lucide-svelte/icons/puzzle';
|
||||
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
|
||||
import Bot from 'lucide-svelte/icons/bot';
|
||||
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
type Provider = (typeof data.providers)[number];
|
||||
type Entry = Provider['entries'][number];
|
||||
type SectionKey = Entry['section'];
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
key: 'connections',
|
||||
label: 'Connections',
|
||||
hint: 'connectors, MCP servers, integrations',
|
||||
icon: Plug,
|
||||
from: '#38bdf8',
|
||||
to: '#2563eb'
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
label: 'Skills',
|
||||
hint: '',
|
||||
icon: WandSparkles,
|
||||
from: '#fbbf24',
|
||||
to: '#ea580c'
|
||||
},
|
||||
{
|
||||
key: 'instructions',
|
||||
label: 'Custom instructions',
|
||||
hint: 'markdown',
|
||||
icon: ScrollText,
|
||||
from: '#a78bfa',
|
||||
to: '#7c3aed'
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
label: 'Plugins & extensions',
|
||||
hint: '',
|
||||
icon: Puzzle,
|
||||
from: '#f472b6',
|
||||
to: '#db2777'
|
||||
},
|
||||
{
|
||||
key: 'routines',
|
||||
label: 'Scheduled tasks & routines',
|
||||
hint: '',
|
||||
icon: CalendarClock,
|
||||
from: '#34d399',
|
||||
to: '#059669'
|
||||
},
|
||||
{ key: 'agents', label: 'Agents & bots', hint: '', icon: Bot, from: '#fb923c', to: '#dc2626' },
|
||||
{
|
||||
key: 'plan',
|
||||
label: 'Plan & API keys',
|
||||
hint: 'labels only',
|
||||
icon: KeyRound,
|
||||
from: '#facc15',
|
||||
to: '#ca8a04'
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
hint: 'markdown',
|
||||
icon: StickyNote,
|
||||
from: '#a1a1aa',
|
||||
to: '#52525b'
|
||||
}
|
||||
] as const satisfies readonly { key: SectionKey }[];
|
||||
const MARKDOWN: SectionKey[] = ['instructions', 'notes'];
|
||||
|
||||
const COLORS = {
|
||||
orange: ['#fdba74', '#c2410c'],
|
||||
green: ['#6ee7b7', '#047857'],
|
||||
blue: ['#93c5fd', '#1d4ed8'],
|
||||
violet: ['#c4b5fd', '#6d28d9'],
|
||||
pink: ['#f9a8d4', '#be185d'],
|
||||
sky: ['#7dd3fc', '#0369a1'],
|
||||
amber: ['#fde68a', '#b45309'],
|
||||
zinc: ['#d4d4d8', '#3f3f46']
|
||||
} as const;
|
||||
type Color = keyof typeof COLORS;
|
||||
const colorKeys = Object.keys(COLORS) as Color[];
|
||||
|
||||
/** Which inline form is open: p:new, p:<id>, e:<id> or n:<providerId>:<section>. */
|
||||
let editing = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
/** Form key of the last submit, so a validation error shows next to the right form. */
|
||||
let lastKey = $state('');
|
||||
|
||||
const open = (key: string) => {
|
||||
editing = editing === key ? null : key;
|
||||
};
|
||||
const submit =
|
||||
(key: string, confirmText?: string): SubmitFunction =>
|
||||
({ cancel }) => {
|
||||
if (confirmText && !confirm(confirmText)) return cancel();
|
||||
busy = true;
|
||||
lastKey = key;
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
busy = false;
|
||||
if (result.type === 'success') editing = null;
|
||||
};
|
||||
};
|
||||
const errorFor = (key: string) => (form?.message && lastKey === key ? form.message : null);
|
||||
|
||||
const grouped = (p: Provider) => {
|
||||
const out = {} as Record<SectionKey, Entry[]>;
|
||||
for (const e of p.entries) (out[e.section] ??= []).push(e);
|
||||
return out;
|
||||
};
|
||||
const stamp = (iso: string) =>
|
||||
new Date(iso).toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const host = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet providerForm(p?: Provider)}
|
||||
{@const key = p ? `p:${p.id}` : 'p:new'}
|
||||
<form
|
||||
class="edit"
|
||||
method="POST"
|
||||
action={p ? '?/updateProvider' : '?/addProvider'}
|
||||
use:enhance={submit(key)}
|
||||
>
|
||||
<input type="hidden" name="form_key" value={key} />
|
||||
{#if p}<input type="hidden" name="id" value={p.id} />{/if}
|
||||
<label>name<input name="name" value={p?.name ?? ''} maxlength="60" required /></label>
|
||||
<label
|
||||
>products<input
|
||||
name="products"
|
||||
value={p?.products ?? ''}
|
||||
maxlength="120"
|
||||
placeholder="e.g. app + CLI"
|
||||
/></label
|
||||
>
|
||||
<fieldset class="colors">
|
||||
<legend>colour</legend>
|
||||
{#each colorKeys as c (c)}
|
||||
<label class="color" title={c}>
|
||||
<input type="radio" name="color" value={c} checked={(p?.color ?? 'zinc') === c} />
|
||||
<span style:--from={COLORS[c][0]} style:--to={COLORS[c][1]}></span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{p ? 'save' : 'add provider'}</button>
|
||||
</div>
|
||||
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
{#snippet entryForm(p: Provider, section: SectionKey, e?: Entry)}
|
||||
{@const key = e ? `e:${e.id}` : `n:${p.id}:${section}`}
|
||||
<form
|
||||
class="edit"
|
||||
method="POST"
|
||||
action={e ? '?/updateEntry' : '?/addEntry'}
|
||||
use:enhance={submit(key)}
|
||||
>
|
||||
<input type="hidden" name="form_key" value={key} />
|
||||
<input type="hidden" name="section" value={section} />
|
||||
{#if e}<input type="hidden" name="id" value={e.id} />{:else}<input
|
||||
type="hidden"
|
||||
name="provider_id"
|
||||
value={p.id}
|
||||
/>{/if}
|
||||
<label class="wide"
|
||||
>name<input
|
||||
name="name"
|
||||
value={e?.name ?? ''}
|
||||
maxlength="120"
|
||||
required
|
||||
placeholder={section === 'plan' ? 'e.g. Pro plan, or a key label like "server key"' : ''}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>scope<select name="scope" value={e?.scope ?? ''}>
|
||||
<option value="">—</option>
|
||||
<option value="global">global</option>
|
||||
<option value="per-repo">per-repo</option>
|
||||
<option value="app">app</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>link<input
|
||||
name="link"
|
||||
type="url"
|
||||
value={e?.link ?? ''}
|
||||
maxlength="500"
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>note<input
|
||||
name="note"
|
||||
value={e?.note ?? ''}
|
||||
maxlength="400"
|
||||
placeholder="short note"
|
||||
/></label
|
||||
>
|
||||
{#if MARKDOWN.includes(section) || e?.body}
|
||||
<label class="wide"
|
||||
>{section === 'instructions' ? 'instructions' : 'details'} (markdown)<textarea
|
||||
name="body"
|
||||
rows="6"
|
||||
value={e?.body ?? ''}
|
||||
maxlength="20000"></textarea></label
|
||||
>
|
||||
{/if}
|
||||
{#if section === 'plan'}
|
||||
<p class="hint wide">
|
||||
<KeyRound size={12} /> Labels only: never paste a key value. Anything that looks like a key or
|
||||
token is refused.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{e ? 'save' : 'add'}</button>
|
||||
</div>
|
||||
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
<div class="bar">
|
||||
<p class="meta">
|
||||
{data.providers.length} providers · connections, skills and instructions per tool · labels only, never
|
||||
keys
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
aria-expanded={editing === 'p:new'}
|
||||
onclick={() => open('p:new')}><Plus size={15} />add provider</button
|
||||
>
|
||||
</div>
|
||||
{#if editing === 'p:new'}
|
||||
<div class="card new">{@render providerForm()}</div>
|
||||
{/if}
|
||||
|
||||
{#if !data.providers.length}
|
||||
<p class="empty">No providers yet.</p>
|
||||
{/if}
|
||||
|
||||
<div class="cards">
|
||||
{#each data.providers as p (p.id)}
|
||||
{@const groups = grouped(p)}
|
||||
{@const [from, to] = COLORS[p.color as Color] ?? COLORS.zinc}
|
||||
{@const emptySections = SECTIONS.filter((s) => !groups[s.key])}
|
||||
<article class="card" style:--from={from} style:--to={to}>
|
||||
<header class="head">
|
||||
<span class="avatar" aria-hidden="true">{p.name.slice(0, 1).toUpperCase()}</span>
|
||||
<div class="title">
|
||||
<h2>{p.name}</h2>
|
||||
{#if p.products}<p class="products">{p.products}</p>{/if}
|
||||
</div>
|
||||
<time datetime={p.updatedAt} title={`${stamp(p.updatedAt)} (Amsterdam)`}
|
||||
>updated {relativeTime(p.updatedAt, data.now)}</time
|
||||
>
|
||||
<div class="tools">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit {p.name}"
|
||||
title="edit"
|
||||
onclick={() => open(`p:${p.id}`)}><Pencil size={14} /></button
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteProvider"
|
||||
use:enhance={submit(
|
||||
`pd:${p.id}`,
|
||||
`Delete ${p.name} and its ${p.entries.length} entries?`
|
||||
)}
|
||||
>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button type="submit" class="icon danger" aria-label="delete {p.name}" title="delete"
|
||||
><Trash2 size={14} /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{#if editing === `p:${p.id}`}{@render providerForm(p)}{/if}
|
||||
|
||||
{#each SECTIONS as s (s.key)}
|
||||
{@const list = groups[s.key]}
|
||||
{#if list || editing === `n:${p.id}:${s.key}`}
|
||||
<details class="section" open={(list?.length ?? 0) <= 8}>
|
||||
<summary>
|
||||
<span class="sicon" style:--from={s.from} style:--to={s.to}><s.icon size={13} /></span
|
||||
>
|
||||
<span class="slabel">{s.label}</span>
|
||||
{#if s.hint}<span class="shint">{s.hint}</span>{/if}
|
||||
<span class="count">{list?.length ?? 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon add"
|
||||
aria-label="add to {s.label}"
|
||||
title="add"
|
||||
onclick={(ev) => {
|
||||
ev.preventDefault();
|
||||
open(`n:${p.id}:${s.key}`);
|
||||
}}><Plus size={14} /></button
|
||||
>
|
||||
</summary>
|
||||
{#if editing === `n:${p.id}:${s.key}`}{@render entryForm(p, s.key)}{/if}
|
||||
{#if list}
|
||||
<ul class="entries">
|
||||
{#each list as e (e.id)}
|
||||
<li class="entry">
|
||||
{#if editing === `e:${e.id}`}
|
||||
{@render entryForm(p, s.key, e)}
|
||||
{:else}
|
||||
<div class="eline">
|
||||
<span class="ename">{e.name}</span>
|
||||
{#if e.scope}<span class="scope scope-{e.scope}">{e.scope}</span>{/if}
|
||||
{#if e.link}
|
||||
<a
|
||||
class="elink"
|
||||
href={e.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={e.link}><ExternalLink size={12} />{host(e.link)}</a
|
||||
>
|
||||
{/if}
|
||||
<span class="etools">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit {e.name}"
|
||||
title="edit"
|
||||
onclick={() => open(`e:${e.id}`)}><Pencil size={13} /></button
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteEntry"
|
||||
use:enhance={submit(`ed:${e.id}`, `Delete “${e.name}”?`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="icon danger"
|
||||
aria-label="delete {e.name}"
|
||||
title="delete"><Trash2 size={13} /></button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{#if e.note}<p class="enote">{e.note}</p>{/if}
|
||||
{#if e.bodyHtml}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written markdown, rendered on the server with raw HTML escaped -->
|
||||
<div class="md">{@html e.bodyHtml}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if s.key === 'plan' && list && editing !== `n:${p.id}:${s.key}`}
|
||||
<p class="hint"><KeyRound size={12} /> Labels only, never key values.</p>
|
||||
{/if}
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if emptySections.some((s) => editing !== `n:${p.id}:${s.key}`)}
|
||||
<div class="chips">
|
||||
{#each emptySections as s (s.key)}
|
||||
{#if editing !== `n:${p.id}:${s.key}`}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => open(`n:${p.id}:${s.key}`)}
|
||||
style:--from={s.from}
|
||||
style:--to={s.to}><span class="sicon"><s.icon size={11} /></span>{s.label}</button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.4rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.icon:hover {
|
||||
color: var(--site-text);
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.icon.danger:hover {
|
||||
color: #f87171;
|
||||
background: rgb(248 113 113 / 0.1);
|
||||
}
|
||||
/* Masonry-style columns: cards differ a lot in height (Grok has dozens of entries). */
|
||||
.cards {
|
||||
columns: 2 26rem;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
.cards > .card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas: 'avatar title tools' 'avatar time tools';
|
||||
column-gap: 0.7rem;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.7rem;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
}
|
||||
.title {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.products {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.head time {
|
||||
grid-area: time;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.tools {
|
||||
grid-area: tools;
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.section {
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.sicon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.45rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
.shint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.entry {
|
||||
padding: 0.35rem 0 0.35rem 1.85rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.entry + .entry {
|
||||
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
|
||||
}
|
||||
.eline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ename {
|
||||
font-weight: 500;
|
||||
}
|
||||
.scope {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.scope-global {
|
||||
color: #38bdf8;
|
||||
border-color: rgb(56 189 248 / 0.4);
|
||||
}
|
||||
.scope-per-repo {
|
||||
color: #a78bfa;
|
||||
border-color: rgb(167 139 250 / 0.4);
|
||||
}
|
||||
.scope-app {
|
||||
color: #34d399;
|
||||
border-color: rgb(52 211 153 / 0.4);
|
||||
}
|
||||
.elink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.elink:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.etools {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.entry:hover .etools,
|
||||
.entry:focus-within .etools {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.etools {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.enote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.md {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-left: 2px solid var(--site-border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md :global(p + p),
|
||||
.md :global(ul),
|
||||
.md :global(ol) {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.md :global(ul) {
|
||||
list-style: disc;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.md :global(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.md :global(code) {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.md :global(a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
||||
border: 1px dashed var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.chip .sicon {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.edit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
.entry .edit {
|
||||
margin-left: -1.85rem;
|
||||
}
|
||||
.edit label,
|
||||
.colors legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.edit .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.edit input:not([type='radio']),
|
||||
.edit select,
|
||||
.edit textarea {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit input:not([type='radio']),
|
||||
.edit select {
|
||||
height: 2.1rem;
|
||||
}
|
||||
.edit textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.colors legend {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.color input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.color span {
|
||||
display: block;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 0.45rem;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color input:checked + span {
|
||||
outline-color: var(--site-text);
|
||||
}
|
||||
.color input:focus-visible + span {
|
||||
outline-color: #38bdf8;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
color: #ca8a04;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.edit .hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.new {
|
||||
padding: 0.2rem 0.85rem 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,9 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
// Owner only: dated usage numbers per provider. Actions don't run the load, so each
|
||||
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
// cache-control comes from the ai layout (owner) and hooks.server.ts; setting it here too threw
|
||||
// header_already_set (500) for the owner.
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {
|
||||
entries: await loadUsage(),
|
||||
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
|
||||
|
||||
11
src/routes/trackers/cargill/+layout.server.ts
Normal file
11
src/routes/trackers/cargill/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { loadPlant } from '#lib/server/plant.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// /trackers/cargill: owner-only notes on what the Cargill Bioindustrial plant in Gouda does.
|
||||
// Everyone else gets a 404 (here and in hooks.server.ts via ownerOnlyPrefixes); hooks.server.ts
|
||||
// also sends cache-control: private, no-store on owner-only paths.
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
return { ...(await loadPlant()), now: Date.now() };
|
||||
};
|
||||
394
src/routes/trackers/cargill/+layout.svelte
Normal file
394
src/routes/trackers/cargill/+layout.svelte
Normal file
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
import KindIcon from '#lib/plant/KindIcon.svelte';
|
||||
import {
|
||||
KIND_LABEL,
|
||||
KIND_TAB,
|
||||
PLANT_KINDS,
|
||||
kindFromSegment,
|
||||
kindFromTab,
|
||||
tabHref,
|
||||
type PlantKind
|
||||
} from '#lib/plant.js';
|
||||
import Factory from 'lucide-svelte/icons/factory';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const c = Object.fromEntries(PLANT_KINDS.map((k) => [k, 0])) as Record<PlantKind, number>;
|
||||
for (const e of data.entries) c[e.kind]++;
|
||||
return c;
|
||||
});
|
||||
// Roles only show up once there is one (they're the least used kind).
|
||||
const tabs = $derived(PLANT_KINDS.filter((k) => k !== 'person_role' || counts[k] > 0));
|
||||
const active = $derived<PlantKind | 'overview'>(
|
||||
page.params.kind
|
||||
? (kindFromSegment(page.params.kind) ?? 'overview')
|
||||
: (kindFromTab(page.url.searchParams.get('tab')) ?? 'overview')
|
||||
);
|
||||
const openQuestions = $derived(
|
||||
data.entries.filter((e) => e.kind === 'question' && !e.answeredAt).length
|
||||
);
|
||||
|
||||
const q = $derived(page.url.searchParams.get('q') ?? '');
|
||||
let typed = $state<string | null>(null);
|
||||
const search = (value: string) => {
|
||||
typed = value;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, used once
|
||||
const params = new URLSearchParams();
|
||||
if (value.trim()) params.set('q', value);
|
||||
const qs = params.size ? `?${params}` : '';
|
||||
void goto(resolve(`/trackers/cargill${qs}` as '/'), {
|
||||
reset: false,
|
||||
replace: page.url.pathname === '/trackers/cargill'
|
||||
});
|
||||
};
|
||||
$effect(() => {
|
||||
void page.url;
|
||||
typed = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="plant">
|
||||
<header class="plant-head">
|
||||
<a href={resolve('/trackers/cargill')} class="title-link">
|
||||
<TrackerIcon name="cargill" class="size-10" />
|
||||
</a>
|
||||
<div class="title">
|
||||
<h1>cargill<PrivateLock size={14} /></h1>
|
||||
<p>What the Cargill Bioindustrial plant in Gouda does: the process, products and words.</p>
|
||||
</div>
|
||||
<label class="search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="search everything"
|
||||
aria-label="Search the plant notes"
|
||||
value={typed ?? q}
|
||||
oninput={(e) => search(e.currentTarget.value)}
|
||||
/>
|
||||
{#if typed ?? q}<button
|
||||
type="button"
|
||||
class="clear"
|
||||
aria-label="clear search"
|
||||
onclick={() => search('')}><X size={14} /></button
|
||||
>{/if}
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<nav class="plant-tabs" aria-label="Plant tracker sections">
|
||||
<a
|
||||
href={resolve('/trackers/cargill')}
|
||||
class={{ active: active === 'overview' && !q }}
|
||||
aria-current={active === 'overview' && !q ? 'page' : undefined}
|
||||
><span class="tab-icon overview"><Factory size={13} /></span>overview</a
|
||||
>
|
||||
{#each tabs as k (k)}
|
||||
<a
|
||||
href={resolve(tabHref(k) as '/')}
|
||||
class={{ active: active === k && !q }}
|
||||
aria-current={active === k && !q ? 'page' : undefined}
|
||||
data-tab={KIND_TAB[k]}
|
||||
><KindIcon kind={k} size={22} />{KIND_LABEL[k].many}<span class="n"
|
||||
>{k === 'question' ? `${openQuestions}/${counts[k]}` : counts[k]}</span
|
||||
></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plant {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.plant-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title-link {
|
||||
display: inline-flex;
|
||||
}
|
||||
.title {
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.title p {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 0 1 17rem;
|
||||
padding: 0 0.6rem;
|
||||
height: 2.2rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
color: var(--site-muted);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.search input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
.clear {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.plant-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
.plant-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.plant-tabs a :global(.kind-icon) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.plant-tabs a:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.plant-tabs a.active {
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.n {
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
}
|
||||
.tab-icon.overview {
|
||||
background: linear-gradient(135deg, #a3e635, #4d7c0f);
|
||||
}
|
||||
|
||||
/* Shared building blocks for the overview and entry pages. */
|
||||
.plant :global(.card) {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.plant :global(.card-title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.plant :global(.bar) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plant :global(.meta),
|
||||
.plant :global(.empty) {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plant :global(.empty) {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
.plant :global(button) {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.plant :global(.primary) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.plant :global(.primary:hover) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.plant :global(.ghost) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
.plant :global(.ghost:hover) {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.plant :global(.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;
|
||||
}
|
||||
.plant :global(.icon:hover) {
|
||||
color: var(--site-text);
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.plant :global(.icon.danger:hover) {
|
||||
color: #f87171;
|
||||
background: rgb(248 113 113 / 0.1);
|
||||
}
|
||||
.plant :global(.tag) {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plant :global(.md) {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.plant :global(.md > * + *) {
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.plant :global(.md h1),
|
||||
.plant :global(.md h2),
|
||||
.plant :global(.md h3) {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.plant :global(.md ul) {
|
||||
list-style: disc;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.plant :global(.md ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.plant :global(.md li + li) {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.plant :global(.md code) {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.plant :global(.md pre) {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--site-text) 6%, transparent);
|
||||
}
|
||||
.plant :global(.md pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.plant :global(.md a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.plant :global(.md strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.plant :global(.md blockquote) {
|
||||
border-left: 2px solid var(--site-border);
|
||||
padding-left: 0.7rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.plant :global(.md table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plant :global(.md th),
|
||||
.plant :global(.md td) {
|
||||
border: 1px solid var(--site-border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.plant {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
src/routes/trackers/cargill/+page.server.ts
Normal file
27
src/routes/trackers/cargill/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { plantActions } from '#lib/server/plant-actions.js';
|
||||
import { renderBody } from '#lib/server/plant.js';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
// The overview shows the "factory in one picture" note, and notes tagged "overview" (the
|
||||
// site overview) at the top.
|
||||
const [intro] = await client`SELECT body FROM plant.entries
|
||||
WHERE kind = 'note' AND slug = 'factory-overview'`;
|
||||
const top = await client`SELECT id, body FROM plant.entries
|
||||
WHERE kind = 'note' AND 'overview' = ANY(tags) AND slug <> 'factory-overview' AND body IS NOT NULL
|
||||
ORDER BY sort_order, id`;
|
||||
return {
|
||||
introHtml: intro?.body ? renderBody(intro.body) : null,
|
||||
topNotes: top.map((n) => ({ id: n.id as number, html: renderBody(n.body) })),
|
||||
seo: {
|
||||
title: 'cargill',
|
||||
description: 'What the Cargill Bioindustrial plant in Gouda does.',
|
||||
index: false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = plantActions;
|
||||
978
src/routes/trackers/cargill/+page.svelte
Normal file
978
src/routes/trackers/cargill/+page.svelte
Normal file
@@ -0,0 +1,978 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import KindIcon from '#lib/plant/KindIcon.svelte';
|
||||
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
|
||||
import EntryForm from '#lib/plant/EntryForm.svelte';
|
||||
import FlowDiagram from '#lib/plant/FlowDiagram.svelte';
|
||||
import {
|
||||
KIND_LABEL,
|
||||
PLANT_KINDS,
|
||||
entryHref,
|
||||
kindFromTab,
|
||||
matches,
|
||||
tabHref,
|
||||
type PlantKind
|
||||
} from '#lib/plant.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
|
||||
import Link2 from 'lucide-svelte/icons/link-2';
|
||||
import Workflow from 'lucide-svelte/icons/workflow';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import Factory from 'lucide-svelte/icons/factory';
|
||||
|
||||
let { data } = $props();
|
||||
type Entry = (typeof data.entries)[number];
|
||||
|
||||
const kind = $derived(kindFromTab(page.url.searchParams.get('tab')));
|
||||
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
|
||||
|
||||
/** Which inline form is open: 'new' or the id of the entry being edited. */
|
||||
let editing = $state<'new' | number | null>(null);
|
||||
$effect(() => {
|
||||
void kind;
|
||||
editing = null;
|
||||
});
|
||||
let busy = $state(false);
|
||||
const confirmSubmit =
|
||||
(text?: string): SubmitFunction =>
|
||||
({ cancel }) => {
|
||||
if (text && !confirm(text)) return cancel();
|
||||
busy = true;
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false, navigate: false });
|
||||
busy = false;
|
||||
};
|
||||
};
|
||||
|
||||
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
|
||||
const linkCount = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const c = new Map<number, number>();
|
||||
for (const l of data.links) {
|
||||
c.set(l.fromId, (c.get(l.fromId) ?? 0) + 1);
|
||||
c.set(l.toId, (c.get(l.toId) ?? 0) + 1);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
const ofKind = (k: PlantKind) =>
|
||||
data.entries
|
||||
.filter((e) => e.kind === k)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title));
|
||||
|
||||
/** Department clusters (FA, EA, DIM, UTIL): tagged "cluster", shown as sections. */
|
||||
const isCluster = (e: Entry) => e.kind === 'department' && e.tags.includes('cluster');
|
||||
const byTitle = (a: Entry, b: Entry) => a.title.localeCompare(b.title, 'en', { numeric: true });
|
||||
const clusters = $derived(
|
||||
data.entries
|
||||
.filter((e) => isCluster(e) && !e.parentId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
);
|
||||
/** Every department below a cluster, depth first (MP1, then its 15U units). */
|
||||
const unitsOf = (c: Entry): Entry[] =>
|
||||
data.entries
|
||||
.filter((u) => u.parentId === c.id && u.kind === 'department')
|
||||
.sort(byTitle)
|
||||
.flatMap((u) => [u, ...unitsOf(u)]);
|
||||
/** Units tagged "inactive" are dimmed, and can be hidden. */
|
||||
const isInactive = (e: Entry) => e.tags.includes('inactive');
|
||||
let hideInactive = $state(false);
|
||||
/** Same-kind children (units under a department group like MP1), shown inside the parent card. */
|
||||
const childrenOf = (e: Entry) =>
|
||||
data.entries
|
||||
.filter((c) => c.parentId === e.id && c.kind === e.kind && !(hideInactive && isInactive(c)))
|
||||
.sort(byTitle);
|
||||
|
||||
const results = $derived(q ? data.entries.filter((e) => matches(e, q)) : []);
|
||||
const confidence = $derived.by(() => {
|
||||
const c = { confirmed: 0, assumed: 0, open: 0 };
|
||||
for (const e of data.entries) c[e.confidence]++;
|
||||
return c;
|
||||
});
|
||||
const questions = $derived(ofKind('question'));
|
||||
const openQuestions = $derived(questions.filter((e) => !e.answeredAt));
|
||||
const recent = $derived(
|
||||
[...data.entries].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
|
||||
);
|
||||
|
||||
// Glossary: A–Z with a filter.
|
||||
let filter = $state('');
|
||||
const letterOf = (t: string) => {
|
||||
const c = t
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
return /[A-Z]/.test(c) ? c : '#';
|
||||
};
|
||||
const glossary = $derived(
|
||||
ofKind('glossary')
|
||||
.filter((e) => matches(e, filter))
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }))
|
||||
);
|
||||
const letters = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const g = new Map<string, Entry[]>();
|
||||
for (const e of glossary) g.set(letterOf(e.title), [...(g.get(letterOf(e.title)) ?? []), e]);
|
||||
return g;
|
||||
});
|
||||
const AZ = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
|
||||
</script>
|
||||
|
||||
{#snippet tools(e: Entry, withEdit = true)}
|
||||
<span class="tools">
|
||||
{#if withEdit}<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit {e.title}"
|
||||
title="edit"
|
||||
onclick={() => (editing = editing === e.id ? null : e.id)}><Pencil size={14} /></button
|
||||
>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={confirmSubmit(`Delete “${e.title}” and its links?`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<button type="submit" class="icon danger" aria-label="delete {e.title}" title="delete"
|
||||
><Trash2 size={14} /></button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet answerToggle(e: Entry)}
|
||||
<form method="POST" action="?/answer" use:enhance={confirmSubmit()}>
|
||||
<input type="hidden" name="id" value={e.id} />
|
||||
<input type="hidden" name="answered" value={e.answeredAt ? '0' : '1'} />
|
||||
<button
|
||||
type="submit"
|
||||
class={['toggle', { done: e.answeredAt }]}
|
||||
disabled={busy}
|
||||
aria-pressed={!!e.answeredAt}
|
||||
title={e.answeredAt ? 'answered: mark as open again' : 'mark as answered'}
|
||||
aria-label={e.answeredAt ? `reopen ${e.title}` : `mark ${e.title} answered`}
|
||||
>{#if e.answeredAt}<CircleCheck size={18} />{:else}<CircleDashed size={18} />{/if}</button
|
||||
>
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
{#snippet row(e: Entry, showKind = false)}
|
||||
{#if editing === e.id}
|
||||
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
|
||||
{:else}
|
||||
<div class={['row', { answered: e.answeredAt, inactive: isInactive(e) }]}>
|
||||
{#if e.kind === 'question'}{@render answerToggle(e)}{:else if showKind}<KindIcon
|
||||
kind={e.kind}
|
||||
size={20}
|
||||
/>{/if}
|
||||
<div class="row-main">
|
||||
<a class="row-title" href={resolve(entryHref(e) as '/')}>{e.title}</a>
|
||||
{#if e.summary}<span class="row-summary">{e.summary}</span>{/if}
|
||||
</div>
|
||||
{#if e.kind !== 'glossary' || e.confidence !== 'confirmed'}<ConfidencePill
|
||||
value={e.confidence}
|
||||
/>{/if}
|
||||
{@render tools(e)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet card(e: Entry)}
|
||||
{#if editing === e.id}
|
||||
<div class="span-all">
|
||||
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
|
||||
</div>
|
||||
{:else}
|
||||
{@const parent = e.parentId ? byId.get(e.parentId) : null}
|
||||
{@const kids = childrenOf(e)}
|
||||
<article class={['card entry-card', { inactive: isInactive(e), group: kids.length }]}>
|
||||
<header>
|
||||
<KindIcon kind={e.kind} size={30} />
|
||||
<div class="ec-title">
|
||||
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
|
||||
{#if parent && parent.kind !== e.kind}<span class="ec-parent"
|
||||
>in <a href={resolve(entryHref(parent) as '/')}>{parent.title}</a></span
|
||||
>{/if}
|
||||
</div>
|
||||
{@render tools(e)}
|
||||
</header>
|
||||
{#if e.summary}<p class="ec-summary">{e.summary}</p>{/if}
|
||||
<footer>
|
||||
<ConfidencePill value={e.confidence} />
|
||||
{#if linkCount.get(e.id)}<span class="tag links"
|
||||
><Link2 size={11} />{linkCount.get(e.id)}</span
|
||||
>{/if}
|
||||
{#each e.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}>{t}</span
|
||||
>{/each}
|
||||
</footer>
|
||||
{#if kids.length}
|
||||
<ul class="kids" aria-label="units in {e.title}">
|
||||
{#each kids as k (k.id)}
|
||||
{#if editing === k.id}
|
||||
<li class="kid-edit">
|
||||
<EntryForm entry={k} entries={data.entries} onclose={() => (editing = null)} />
|
||||
</li>
|
||||
{:else}
|
||||
<li class={['row', { inactive: isInactive(k) }]}>
|
||||
<KindIcon kind={k.kind} size={18} />
|
||||
<div class="row-main">
|
||||
<a class="row-title" href={resolve(entryHref(k) as '/')}>{k.title}</a>
|
||||
{#if k.summary}<span class="row-summary">{k.summary}</span>{/if}
|
||||
<span class="kid-meta">
|
||||
<ConfidencePill value={k.confidence} />
|
||||
{#if isInactive(k)}<span class="tag tag-inactive">inactive</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{@render tools(k)}
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet addBar(k: PlantKind | null, meta: string)}
|
||||
<div class="bar">
|
||||
<p class="meta">{meta}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
aria-expanded={editing === 'new'}
|
||||
onclick={() => (editing = editing === 'new' ? null : 'new')}
|
||||
><Plus size={15} />add {k ? KIND_LABEL[k].one : 'entry'}</button
|
||||
>
|
||||
</div>
|
||||
{#if editing === 'new'}
|
||||
<EntryForm
|
||||
kind={k ?? 'note'}
|
||||
entries={data.entries}
|
||||
onclose={() => (editing = null)}
|
||||
openAfterCreate={!k}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if q}
|
||||
<p class="meta">
|
||||
{results.length}
|
||||
{results.length === 1 ? 'match' : 'matches'} for “{q}”
|
||||
</p>
|
||||
{#if results.length}
|
||||
<div class="card rows">
|
||||
{#each results as e (e.id)}{@render row(e, true)}{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Nothing found. Try another word, or add it as a question.</p>
|
||||
{/if}
|
||||
{:else if !kind}
|
||||
{#each data.topNotes as n (n.id)}
|
||||
{@const note = byId.get(n.id)}
|
||||
{#if note}
|
||||
<section class="card site-note">
|
||||
<h2 class="card-title">
|
||||
<StickyNote size={14} />{note.title}
|
||||
<a class="more" href={resolve(entryHref(note) as '/')}>open</a>
|
||||
</h2>
|
||||
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="md">{@html n.html}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{@render addBar(
|
||||
null,
|
||||
`${data.entries.length} entries · ${confidence.confirmed} confirmed, ${confidence.assumed} assumed, ${confidence.open} open`
|
||||
)}
|
||||
<div class="kinds">
|
||||
{#each PLANT_KINDS as k (k)}
|
||||
{@const n = data.entries.filter((e) => e.kind === k).length}
|
||||
{#if n || k !== 'person_role'}
|
||||
<a class="kind-stat" href={resolve(tabHref(k) as '/')}>
|
||||
<KindIcon kind={k} size={26} />
|
||||
<span class="ks-n">{n}</span>
|
||||
<span class="ks-l">{KIND_LABEL[k].many}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if clusters.length}
|
||||
<section class="card clusters-card">
|
||||
<h2 class="card-title">
|
||||
<Factory size={14} />departments by cluster
|
||||
<a class="more" href={resolve(tabHref('department') as '/')}
|
||||
>all {ofKind('department').length}</a
|
||||
>
|
||||
</h2>
|
||||
<div class="clusters">
|
||||
{#each clusters as c (c.id)}
|
||||
<div class="cluster-mini">
|
||||
<a class="cm-title" href={resolve(entryHref(c) as '/')}
|
||||
><KindIcon kind="department" size={20} />{c.title}</a
|
||||
>
|
||||
{#if c.summary}<p class="cm-summary">{c.summary}</p>{/if}
|
||||
<div class="chips">
|
||||
{#each unitsOf(c) as u (u.id)}
|
||||
<a
|
||||
class={['chip', { inactive: isInactive(u) }]}
|
||||
href={resolve(entryHref(u) as '/')}
|
||||
title={u.summary ?? u.title}>{u.title}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="overview">
|
||||
<section class="card flow-card">
|
||||
<h2 class="card-title"><Workflow size={14} />how it flows</h2>
|
||||
<FlowDiagram entries={data.entries} links={data.links} />
|
||||
<p class="legend">
|
||||
<span><i class="lg step"></i>process step</span>
|
||||
<span><i class="lg terminal"></i>in / out</span>
|
||||
<span><i class="lg dashed"></i>optional or loop</span>
|
||||
</p>
|
||||
</section>
|
||||
<div class="side">
|
||||
{#if data.introHtml}
|
||||
<section class="card">
|
||||
<h2 class="card-title"><StickyNote size={14} />in one picture</h2>
|
||||
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="md">{@html data.introHtml}</div>
|
||||
</section>
|
||||
{/if}
|
||||
<section class="card">
|
||||
<h2 class="card-title">
|
||||
<CircleQuestionMark size={14} />open questions
|
||||
<a class="more" href={resolve(tabHref('question') as '/')}>all {questions.length}</a>
|
||||
</h2>
|
||||
{#if openQuestions.length}
|
||||
<div class="rows">
|
||||
{#each openQuestions as e (e.id)}{@render row(e)}{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="meta">All answered.</p>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="card-title"><History size={14} />recently updated</h2>
|
||||
<ul class="recent">
|
||||
{#each recent as e (e.id)}
|
||||
<li>
|
||||
<KindIcon kind={e.kind} size={18} />
|
||||
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
|
||||
<time datetime={e.updatedAt}>{relativeTime(e.updatedAt, data.now)}</time>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else if kind === 'glossary'}
|
||||
{@render addBar(kind, `${ofKind('glossary').length} words, A–Z`)}
|
||||
<div class="gloss-tools">
|
||||
<input
|
||||
class="filter"
|
||||
type="search"
|
||||
placeholder="filter words"
|
||||
aria-label="Filter the glossary"
|
||||
bind:value={filter}
|
||||
/>
|
||||
<nav class="az" aria-label="Jump to letter">
|
||||
{#each AZ as l (l)}
|
||||
{#if letters.has(l)}<a href="#letter-{l}">{l}</a>{:else}<span>{l}</span>{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{#if !glossary.length}<p class="empty">No words match.</p>{/if}
|
||||
<div class="gloss">
|
||||
{#each [...letters] as [l, words] (l)}
|
||||
<section class="card letter" id="letter-{l}">
|
||||
<h2 class="big-letter">{l}</h2>
|
||||
<div class="rows">
|
||||
{#each words as e (e.id)}{@render row(e)}{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if kind === 'question'}
|
||||
{@render addBar(
|
||||
kind,
|
||||
`${openQuestions.length} open · ${questions.length - openQuestions.length} answered`
|
||||
)}
|
||||
<section class="card rows">
|
||||
{#each openQuestions as e (e.id)}{@render row(e)}{:else}<p class="meta">
|
||||
No open questions.
|
||||
</p>{/each}
|
||||
</section>
|
||||
{#if questions.length > openQuestions.length}
|
||||
<h2 class="sub">answered</h2>
|
||||
<section class="card rows">
|
||||
{#each questions.filter((e) => e.answeredAt) as e (e.id)}{@render row(e)}{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const list = ofKind(kind)}
|
||||
{@const inactiveCount = list.filter(isInactive).length}
|
||||
{@const top = list.filter(
|
||||
(e) => (!e.parentId || byId.get(e.parentId)?.kind !== kind) && !(hideInactive && isInactive(e))
|
||||
)}
|
||||
{@render addBar(
|
||||
kind,
|
||||
`${list.length} ${KIND_LABEL[kind].many}${inactiveCount ? ` · ${inactiveCount} inactive` : ''}`
|
||||
)}
|
||||
{#if inactiveCount}
|
||||
<label class="switch">
|
||||
<input type="checkbox" bind:checked={hideInactive} />
|
||||
<span class="knob" aria-hidden="true"></span>hide inactive
|
||||
</label>
|
||||
{/if}
|
||||
{#if kind === 'process_step'}
|
||||
<section class="card flow-card">
|
||||
<FlowDiagram entries={data.entries} links={data.links} />
|
||||
</section>
|
||||
{/if}
|
||||
{#if !list.length}
|
||||
<p class="empty">
|
||||
Nothing here yet.{kind === 'person_role'
|
||||
? ' Add roles (operator, shift lead, lab analyst …), not names.'
|
||||
: ''}
|
||||
</p>
|
||||
{/if}
|
||||
{@const groups = top.filter(isCluster)}
|
||||
{#if groups.length}
|
||||
{#each groups as c (c.id)}
|
||||
{@const units = childrenOf(c)}
|
||||
<section class={['cluster', { inactive: isInactive(c) }]} aria-labelledby="cluster-{c.id}">
|
||||
<header class="cluster-head">
|
||||
<KindIcon kind={c.kind} size={26} />
|
||||
<div class="ch-text">
|
||||
<h2 id="cluster-{c.id}"><a href={resolve(entryHref(c) as '/')}>{c.title}</a></h2>
|
||||
{#if c.summary}<p class="ec-summary">{c.summary}</p>{/if}
|
||||
</div>
|
||||
<span class="tag">{units.length} units</span>
|
||||
<ConfidencePill value={c.confidence} />
|
||||
{@render tools(c)}
|
||||
</header>
|
||||
{#if editing === c.id}
|
||||
<EntryForm entry={c} entries={data.entries} onclose={() => (editing = null)} />
|
||||
{/if}
|
||||
<div class="grid">
|
||||
{#each units as e (e.id)}{@render card(e)}{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{@const rest = top.filter((e) => !isCluster(e))}
|
||||
{#if rest.length}
|
||||
<section class="cluster" aria-labelledby="cluster-none">
|
||||
<header class="cluster-head">
|
||||
<div class="ch-text">
|
||||
<h2 id="cluster-none">not in a cluster</h2>
|
||||
<p class="ec-summary">General entries, or units still to place.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid">
|
||||
{#each rest as e (e.id)}{@render card(e)}{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each [...top].sort((a, b) => childrenOf(b).length - childrenOf(a).length) as e (e.id)}{@render card(
|
||||
e
|
||||
)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kinds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kind-stat {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: 'icon n' 'icon l';
|
||||
column-gap: 0.55rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--site-surface);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.kind-stat:hover {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.kind-stat :global(.kind-icon) {
|
||||
grid-area: icon;
|
||||
}
|
||||
.ks-n {
|
||||
grid-area: n;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ks-l {
|
||||
grid-area: l;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.flow-card {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.lg {
|
||||
display: inline-block;
|
||||
width: 1.3rem;
|
||||
height: 0.75rem;
|
||||
border: 1.2px solid var(--site-muted);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.lg.terminal {
|
||||
border-style: dashed;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.lg.dashed {
|
||||
height: 0;
|
||||
border-width: 1.2px 0 0;
|
||||
border-style: dashed;
|
||||
border-radius: 0;
|
||||
}
|
||||
.more {
|
||||
margin-left: auto;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.more:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.recent li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.recent a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent a:hover,
|
||||
.row-title:hover,
|
||||
.ec-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.recent time {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card.rows {
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.rows > :global(* + *) {
|
||||
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
|
||||
}
|
||||
.rows > :global(form.edit) {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.row-summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.row.answered .row-title {
|
||||
color: var(--site-muted);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
|
||||
}
|
||||
.tools {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.row:hover .tools,
|
||||
.row:focus-within .tools,
|
||||
.entry-card:hover .tools,
|
||||
.entry-card:focus-within .tools,
|
||||
.cluster-head:hover .tools,
|
||||
.cluster-head:focus-within .tools {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.tools {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
color: #ec4899;
|
||||
padding: 0.1rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.toggle.done {
|
||||
color: #10b981;
|
||||
}
|
||||
.toggle:hover {
|
||||
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
}
|
||||
.span-all {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.entry-card:hover {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.entry-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.ec-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ec-title > a {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ec-parent {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ec-summary {
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.entry-card footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.entry-card.inactive,
|
||||
.row.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.entry-card.inactive :global(.kind-icon),
|
||||
.row.inactive :global(.kind-icon) {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
.entry-card.inactive:hover,
|
||||
.row.inactive:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.tag-inactive {
|
||||
border-style: dashed;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.kid-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.clusters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cluster-mini {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.cm-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cm-summary {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.chip.inactive {
|
||||
opacity: 0.45;
|
||||
border-style: dashed;
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.cluster-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.45rem;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
}
|
||||
.ch-text {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.ch-text h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ch-text .ec-summary {
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.entry-card.group {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.kids {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
|
||||
gap: 0.4rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
.kids .row {
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.kid-edit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.kids .row-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
.knob {
|
||||
position: relative;
|
||||
width: 1.9rem;
|
||||
height: 1.05rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.knob::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.15rem;
|
||||
left: 0.15rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-bg);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.switch input:checked + .knob {
|
||||
background: var(--site-text);
|
||||
}
|
||||
.switch input:checked + .knob::after {
|
||||
transform: translateX(0.85rem);
|
||||
}
|
||||
.switch input:focus-visible + .knob {
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tag.links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.gloss-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
.filter {
|
||||
flex: 0 1 14rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.az {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.1rem;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.az a,
|
||||
.az span {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
padding: 0.1rem 0;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.az a {
|
||||
font-weight: 600;
|
||||
}
|
||||
.az a:hover {
|
||||
background: color-mix(in srgb, var(--site-text) 10%, transparent);
|
||||
}
|
||||
.az span {
|
||||
color: var(--site-muted);
|
||||
opacity: 0.45;
|
||||
}
|
||||
.gloss {
|
||||
columns: 2 22rem;
|
||||
column-gap: 0.6rem;
|
||||
}
|
||||
.letter {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.big-letter {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
padding-top: 0.35rem;
|
||||
background: linear-gradient(135deg, #6ee7b7, #047857);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/routes/trackers/cargill/[kind]/[slug]/+page.server.ts
Normal file
34
src/routes/trackers/cargill/[kind]/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { kindFromSegment } from '#lib/plant.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { plantActions } from '#lib/server/plant-actions.js';
|
||||
import { renderBody } from '#lib/server/plant.js';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// One entry: its markdown body, parent, children and links (entries and links come from the
|
||||
// layout load).
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
const kind = kindFromSegment(event.params.kind);
|
||||
if (!kind) error(404, 'Not found');
|
||||
const [row] = await client`SELECT id, title, summary, body FROM plant.entries
|
||||
WHERE kind = ${kind} AND slug = ${event.params.slug}`;
|
||||
if (!row) error(404, 'Not found');
|
||||
// Parents and links whose target isn't in the tracker yet (a later import resolves them).
|
||||
const pending = await client`SELECT id, field, to_ref, label FROM plant.pending
|
||||
WHERE from_id = ${row.id} ORDER BY id`;
|
||||
return {
|
||||
entryId: row.id as number,
|
||||
pending: pending.map((p) => ({
|
||||
id: p.id as number,
|
||||
field: p.field as string,
|
||||
to: p.to_ref as string,
|
||||
label: p.label as string | null
|
||||
})),
|
||||
bodyHtml: row.body ? renderBody(row.body) : null,
|
||||
seo: { title: `${row.title} · cargill`, description: row.summary ?? '', index: false }
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = plantActions;
|
||||
512
src/routes/trackers/cargill/[kind]/[slug]/+page.svelte
Normal file
512
src/routes/trackers/cargill/[kind]/[slug]/+page.svelte
Normal file
@@ -0,0 +1,512 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import KindIcon from '#lib/plant/KindIcon.svelte';
|
||||
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
|
||||
import EntryForm from '#lib/plant/EntryForm.svelte';
|
||||
import {
|
||||
KIND_LABEL,
|
||||
PLANT_KINDS,
|
||||
RELATIONS,
|
||||
RELATION_LABEL,
|
||||
entryHref,
|
||||
tabHref,
|
||||
type Relation
|
||||
} from '#lib/plant.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
|
||||
import Link2 from 'lucide-svelte/icons/link-2';
|
||||
import Unlink from 'lucide-svelte/icons/unlink';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
let { data, form } = $props();
|
||||
type Entry = (typeof data.entries)[number];
|
||||
|
||||
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
|
||||
const entry = $derived(byId.get(data.entryId)!);
|
||||
const parentChain = $derived.by(() => {
|
||||
const chain: Entry[] = [];
|
||||
let p = entry?.parentId ? byId.get(entry.parentId) : undefined;
|
||||
while (p && chain.length < 5 && !chain.includes(p)) {
|
||||
chain.unshift(p);
|
||||
p = p.parentId ? byId.get(p.parentId) : undefined;
|
||||
}
|
||||
return chain;
|
||||
});
|
||||
const children = $derived(
|
||||
data.entries
|
||||
.filter((e) => e.parentId === data.entryId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title))
|
||||
);
|
||||
/** Links grouped by how they read from this entry ("feeds into", "fed by", ...). */
|
||||
const linkGroups = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const groups = new Map<string, { link: (typeof data.links)[number]; other: Entry }[]>();
|
||||
for (const relation of RELATIONS)
|
||||
for (const dir of ['out', 'in'] as const) {
|
||||
const label = RELATION_LABEL[relation][dir];
|
||||
for (const l of data.links) {
|
||||
if (l.relation !== relation) continue;
|
||||
const mine = dir === 'out' ? l.fromId : l.toId;
|
||||
if (mine !== data.entryId) continue;
|
||||
const other = byId.get(dir === 'out' ? l.toId : l.fromId);
|
||||
if (other) groups.set(label, [...(groups.get(label) ?? []), { link: l, other }]);
|
||||
}
|
||||
}
|
||||
return [...groups];
|
||||
});
|
||||
const linkTotal = $derived(linkGroups.reduce((s, [, g]) => s + g.length, 0));
|
||||
|
||||
let editing = $state(false);
|
||||
let linking = $state(false);
|
||||
$effect(() => {
|
||||
void data.entryId;
|
||||
editing = false;
|
||||
linking = false;
|
||||
});
|
||||
let busy = $state(false);
|
||||
let linkError = $state<string | null>(null);
|
||||
const submit =
|
||||
(confirmText?: string, after?: () => void): SubmitFunction =>
|
||||
({ cancel }) => {
|
||||
if (confirmText && !confirm(confirmText)) return cancel();
|
||||
busy = true;
|
||||
linkError = null;
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false, navigate: false });
|
||||
busy = false;
|
||||
if (result.type === 'failure') linkError = String(result.data?.message ?? 'Failed');
|
||||
if (result.type === 'success') after?.();
|
||||
};
|
||||
};
|
||||
let reverse = $state(false);
|
||||
let relation = $state<Relation>('related');
|
||||
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'
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if entry}
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<a href={resolve(tabHref(entry.kind) as '/')}
|
||||
><ArrowLeft size={14} />{KIND_LABEL[entry.kind].many}</a
|
||||
>
|
||||
{#each parentChain as p (p.id)}
|
||||
<span>/</span><a href={resolve(entryHref(p) as '/')}>{p.title}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="layout">
|
||||
<article class="card main">
|
||||
<header class="head">
|
||||
<KindIcon kind={entry.kind} size={40} />
|
||||
<div class="head-text">
|
||||
<p class="kind">{KIND_LABEL[entry.kind].one}</p>
|
||||
<h2 class={{ answered: entry.answeredAt }}>{entry.title}</h2>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="edit"
|
||||
title="edit"
|
||||
aria-expanded={editing}
|
||||
onclick={() => (editing = !editing)}><Pencil size={16} /></button
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={submit(`Delete “${entry.title}” and its links?`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={entry.id} />
|
||||
<input type="hidden" name="redirect" value="1" />
|
||||
<button type="submit" class="icon danger" aria-label="delete" title="delete"
|
||||
><Trash2 size={16} /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<div class="badges">
|
||||
<ConfidencePill value={entry.confidence} />
|
||||
{#if entry.kind === 'question'}
|
||||
<form method="POST" action="?/answer" use:enhance={submit()}>
|
||||
<input type="hidden" name="id" value={entry.id} />
|
||||
<input type="hidden" name="answered" value={entry.answeredAt ? '0' : '1'} />
|
||||
<button
|
||||
type="submit"
|
||||
class={['answer', { done: entry.answeredAt }]}
|
||||
disabled={busy}
|
||||
aria-pressed={!!entry.answeredAt}
|
||||
>{#if entry.answeredAt}<CircleCheck size={14} />answered {relativeTime(
|
||||
entry.answeredAt,
|
||||
data.now
|
||||
)}{:else}<CircleDashed size={14} />mark answered{/if}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
{#each entry.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}
|
||||
>{t}</span
|
||||
>{/each}
|
||||
</div>
|
||||
{#if editing}
|
||||
<EntryForm {entry} entries={data.entries} onclose={() => (editing = false)} />
|
||||
{:else}
|
||||
{#if entry.summary}<p class="summary">{entry.summary}</p>{/if}
|
||||
{#if data.bodyHtml}
|
||||
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="md">{@html data.bodyHtml}</div>
|
||||
{:else if !entry.summary}
|
||||
<p class="meta">No details yet.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<footer class="foot">
|
||||
{#if entry.source}<span>source: {entry.source}</span>{/if}
|
||||
<time datetime={entry.updatedAt} title="{stamp(entry.updatedAt)} (Amsterdam)"
|
||||
>updated {relativeTime(entry.updatedAt, data.now)}</time
|
||||
>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<aside class="side">
|
||||
<section class="card">
|
||||
<h3 class="card-title">
|
||||
<Link2 size={14} />linked {linkTotal ? `(${linkTotal})` : ''}
|
||||
<button
|
||||
type="button"
|
||||
class="icon add-link"
|
||||
aria-label="add a link"
|
||||
title="add a link"
|
||||
aria-expanded={linking}
|
||||
onclick={() => (linking = !linking)}><Plus size={15} /></button
|
||||
>
|
||||
</h3>
|
||||
{#if linking}
|
||||
<form
|
||||
class="link-form"
|
||||
method="POST"
|
||||
action="?/link"
|
||||
use:enhance={submit(undefined, () => (linking = false))}
|
||||
>
|
||||
<input type="hidden" name={reverse ? 'to_id' : 'from_id'} value={entry.id} />
|
||||
<div class="lf-row">
|
||||
<select bind:value={reverse} aria-label="direction">
|
||||
<option value={false}>this → other</option>
|
||||
<option value={true}>other → this</option>
|
||||
</select>
|
||||
<select name="relation" bind:value={relation} aria-label="relation">
|
||||
{#each RELATIONS as r (r)}<option value={r}>{RELATION_LABEL[r].out}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<select name={reverse ? 'from_id' : 'to_id'} required aria-label="other entry">
|
||||
<option value="">pick an entry…</option>
|
||||
{#each PLANT_KINDS as k (k)}
|
||||
{@const options = data.entries.filter((e) => e.kind === k && e.id !== entry.id)}
|
||||
{#if options.length}
|
||||
<optgroup label={KIND_LABEL[k].many}>
|
||||
{#each options as o (o.id)}<option value={o.id}>{o.title}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<div class="lf-row">
|
||||
<input name="label" maxlength="80" placeholder="label (optional)" />
|
||||
<label class="check"><input type="checkbox" name="dashed" value="1" />dashed</label>
|
||||
</div>
|
||||
<div class="lf-row end">
|
||||
<button type="button" class="ghost" onclick={() => (linking = false)}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>link</button>
|
||||
</div>
|
||||
{#if linkError}<p class="error" role="alert">{linkError}</p>{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{#if form?.message && !linking && !editing}<p class="error" role="alert">
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
{#each linkGroups as [label, items] (label)}
|
||||
<div class="group">
|
||||
<p class="group-label">{label}</p>
|
||||
<ul>
|
||||
{#each items as { link, other } (link.id)}
|
||||
<li>
|
||||
<KindIcon kind={other.kind} size={20} />
|
||||
<a href={resolve(entryHref(other) as '/')}>{other.title}</a>
|
||||
{#if link.label}<span class="tag">{link.label}</span>{/if}
|
||||
{#if link.confidence !== 'confirmed'}<span
|
||||
class="tag maybe"
|
||||
title="{link.confidence} link, not confirmed yet">?</span
|
||||
>{/if}
|
||||
<form method="POST" action="?/unlink" use:enhance={submit()}>
|
||||
<input type="hidden" name="id" value={link.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="icon danger unlink"
|
||||
aria-label="unlink {other.title}"
|
||||
title="unlink"><Unlink size={13} /></button
|
||||
>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !linking && !data.pending.length}<p class="meta">No links yet.</p>{/if}
|
||||
{/each}
|
||||
{#if data.pending.length}
|
||||
<div class="group pending">
|
||||
<p class="group-label">pending</p>
|
||||
<ul>
|
||||
{#each data.pending as p (p.id)}
|
||||
<li title="Not in the tracker yet; resolves when an entry with this slug is added">
|
||||
<span class="pending-dot" aria-hidden="true"></span>
|
||||
<span class="pending-ref">{p.to}</span>
|
||||
<span class="tag"
|
||||
>{p.field === 'parent'
|
||||
? 'part of'
|
||||
: (RELATION_LABEL[p.field as Relation]?.out ?? p.field)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{#if children.length}
|
||||
<section class="card">
|
||||
<h3 class="card-title">contains ({children.length})</h3>
|
||||
<ul class="group">
|
||||
{#each children as c (c.id)}
|
||||
<li>
|
||||
<KindIcon kind={c.kind} size={20} />
|
||||
<a href={resolve(entryHref(c) as '/')}>{c.title}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.crumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.crumbs a:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.head-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kind {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
h2.answered {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.tag-inactive {
|
||||
border-style: dashed;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.answer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgb(236 72 153 / 0.4);
|
||||
color: #ec4899;
|
||||
}
|
||||
.answer.done {
|
||||
border-color: rgb(16 185 129 / 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
.summary {
|
||||
font-size: 1rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.add-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
.group + .group {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.maybe {
|
||||
border-style: dashed;
|
||||
color: #f59e0b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pending li {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pending-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--site-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pending-ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.group li,
|
||||
ul.group li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.86rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.group li a {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.group li form {
|
||||
margin-left: auto;
|
||||
}
|
||||
.unlink {
|
||||
opacity: 0;
|
||||
}
|
||||
.group li:hover .unlink,
|
||||
.unlink:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.unlink {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.link-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
}
|
||||
.lf-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.lf-row.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.link-form select,
|
||||
.link-form input:not([type='checkbox']) {
|
||||
font: inherit;
|
||||
font-size: 0.83rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.45rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
src/routes/trackers/math/+page.server.ts
Normal file
33
src/routes/trackers/math/+page.server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
1203
src/routes/trackers/math/+page.svelte
Normal file
1203
src/routes/trackers/math/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
BIN
static/trackers/math/napkin-chart.webp
Normal file
BIN
static/trackers/math/napkin-chart.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Reference in New Issue
Block a user