26 Commits

Author SHA1 Message Date
4ef75f9d25 stats: bare theme URLs (/stats/jobs) open the core selection
All checks were successful
Deploy production / deploy (push) Successful in 1m20s
2026-10-26 18:10:12 +00:00
b566a1244f stats: jobs & cost of living theme (unemployment by age, vacancies, JOLTS, graduates, job quality, wages, price levels, housing), grad view cards, Beveridge curve; jobs collector (OECD SDMX, Big Mac index, derived series) + Eurostat/World Bank/FRED jobs series, stats-jobs.timer
All checks were successful
Deploy production / deploy (push) Successful in 1m20s
2026-10-26 18:08:06 +00:00
f2f7bd0a19 stats: place × theme matrix (/stats/<place>/<theme>), sticky place/theme selector, generic metric engine with comparisons, 301s from the old section URLs
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-26 17:37:47 +00:00
7ba26f132d radio: quiet hours default 00:00-08:00; banks 1-3 fill script (ISS, calling, Gouda-area repeaters); docs
All checks were successful
Deploy production / deploy (push) Successful in 1m20s
2026-10-26 17:24:03 +00:00
f954881bcc radio: quiet hours for reminders (default 23:00-07:30 Amsterdam, RADIO_QUIET_HOURS overrides)
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
2026-10-26 17:18:59 +00:00
95a8b76562 radio: fix quick log insert (timestamp param), 'ch 16' falls back to the marine channel, ISS list fits half-width cards
All checks were successful
Deploy production / deploy (push) Successful in 1m26s
2026-10-26 17:15:29 +00:00
03574a95a7 radio: private radio tracker (/trackers/radio): frequencies, UBC125XLT banks + CSV, nets, ISS passes over Gouda, listening log, ntfy reminders
All checks were successful
Deploy production / deploy (push) Successful in 1m26s
2026-10-26 16:58:29 +00:00
e24ee7f95d plant: private Cargill Gouda tracker (/trackers/cargill)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
Owner-only tracker for learning the Cargill Bioindustrial plant in Gouda:
schema plant (entries, links with confidence, pending refs), plant.apply_entries
bulk import, overview with clusters and flow diagram, kind tabs, glossary,
questions, detail pages and owner inline editing. Docs in docs/plant-tracker.md.
2026-10-26 13:42:37 +00:00
fda21554cc math: make tracker public (read-only for visitors)
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
2026-10-26 12:35:15 +00:00
d7e26a3338 math tracker: pannable 2D map of the Napkin (default), list view kept behind ?view=list
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
Chapters are nodes grouped into part regions laid out like the book's dependency chart
(roots Abs Alg, Topology, Lin Alg, Set Theory flowing down to AG, AT and ANT). Part
prereqs are routed edges, optional ones (\coreqc in the book) dotted. Status colours,
exercise rings, current-chapter glow, legend, search, minimap, pinch/drag/wheel zoom,
fit and +/- buttons. Tapping a chapter opens the existing panel (side panel on desktop,
bottom sheet on mobile). The view choice is remembered per device.
2026-10-26 12:22:38 +00:00
44379b9a27 math tracker: space before the skipped count
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
2026-10-26 11:22:53 +00:00
f7a3bb556c math tracker: pass string classes to lucide icons (fixes owner-view 500)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-26 11:14:03 +00:00
16684d6688 math tracker: own icon colour (blue sigma tile; indigo is games)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-26 11:09:36 +00:00
d99245e9fc Math tracker (/trackers/math): Evan Chen's Napkin, chapter by chapter
Some checks failed
Deploy production / deploy (push) Failing after 9s
Schema math (parts, chapters, sections, activity), seeded with all 100 chapters
(Ch 0-94, appendix A-E), 647 sections, PDF pages, exercise counts and approximate
prereqs. Triggers stamp started_at/completed_at and log status/exercise/note/section
changes; math.apply_progress(jsonb) applies the JSON format bots send. Private by
default via MATH_PRIVATE in #lib/math (owner-only prefix + lock in every tracker list).
Overall + per-part progress, current chapter, prereq hints, quick status buttons,
exercises stepper, notes, optional section ticks, activity log, dependency chart.
Deploy preflight checks the math schema (4 tables, 100 chapters).
2026-10-26 10:55:16 +00:00
23a43049a0 cs-prices: lime for CSFloat history so it stands apart from CSFloat listings
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:35:36 +00:00
3b75fd39b5 AI setup tab: masonry columns, even form controls, one labels-only hint
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-26 09:34:38 +00:00
76f063b362 cs-prices: price history backfill (Steam daily median since 2013 from the public listing page, CSFloat daily averages, ECB FX history) in separate *-history streams; labelled on item charts
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:32:55 +00:00
7396d23335 AI tracker: owner-only tabs no longer 500 (cache-control set twice: layout + page load)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 09:27:26 +00:00
22279d181f deploy preflight: ai schema now has 5 tables (setup tab)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 09:23:35 +00:00
8d2da984e0 AI tracker: owner-only Setup tab (per-provider connections, skills, instructions, plugins, routines, agents, plan/key labels, notes; ai.provider_setup_providers + provider_setup_entries, secrets refused)
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
2026-10-26 09:09:32 +00:00
9e89a615c6 cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly
- every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases
- cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163
- cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB)
- scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index
- skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts
- charts show points for short histories and pad sparse time axes
2026-10-26 08:47:57 +00:00
9d79dfb43a worldwide todo list: global map (List/Map toggle, clustered category pins, Global strip, pick on map, location_precision, tasks.geojson)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 08:38:51 +00:00
21b10ea894 Money forecast: name the first crash after the calm start in the explanation
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 08:29:28 +00:00
78b69c7d8f Money forecast: calm optimistic start, rising plateau, thin solid projected lines
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00
d3b9bb9ba2 money: tag interest/dividend payouts on bank sync and Revolut import (category interest|dividend, never overwrites a set category)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:48:47 +00:00
0121071af2 Tasks goals: investment income in the €10k/month bar (trailing-12m money.transactions amount_cents>0, category dividend|interest, any account except Joint, ÷12); own segment colour + breakdown line, breakdown amounts with cents
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 07:35:09 +00:00
186 changed files with 322993 additions and 1042 deletions

View File

@@ -26,6 +26,8 @@ COPY --from=builder /app/src/lib/music ./src/lib/music
COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest
# scripts/ai-*-import.ts (AI tracker news and bot status imports) import this.
COPY --from=builder /app/src/lib/server/ai-import.ts ./src/lib/server/ai-import.ts
# scripts/radio.ts (ISS passes and ntfy reminders) imports the radio helpers.
COPY --from=builder /app/src/lib/radio ./src/lib/radio
COPY --from=builder /app/server.js ./server.js
COPY package.json ./
EXPOSE 3000

View File

@@ -21,6 +21,7 @@
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9",
"satellite.js": "^7.1.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4",
@@ -939,6 +940,8 @@
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"satellite.js": ["satellite.js@7.1.0", "", {}, "sha512-U6nRml9Nb7dV9LJPiMNPyna7U7ry+1nXYkOeCEG7K/YbojQQLHHmcjPisp03VNY+HLcbQHqbt7t4t1vQMaKCLQ=="],
"semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],

View File

@@ -3,33 +3,34 @@
Host-side collector for the public [/stats](https://stanrunge.dev/stats) section. It pulls open
statistics into the `stats` schema with `scripts/stats-fetch.ts`:
| source | timer | schedule (Europe/Amsterdam) | what |
| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) |
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) |
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) |
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries |
| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries |
| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields |
| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) |
| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags |
| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset |
| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) |
| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) |
| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) |
| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year |
| `eponline` | `stats-eponline.timer` | 3rd of the month 05:20 | EP-Online monthly total file (RVO energy labels): homes per label for NL, Gouda and Gouda PC4s; aggregates only. Needs `EP_ONLINE_API_KEY` in `~/.config/stats/ep-online.env` (mode 600), which the runner exports for this source only |
| `elections` | `stats-elections.timer` | 15th of the month 04:50 | A14: Kiesraad Tweede Kamer results 2010→ (data.overheid.nl CSV + EML zips read with HTTP ranges; national, municipality, Gouda polling stations), CBS 37278 (1918–2006), MIT US presidential by state. Finished elections are skipped unless `--force` |
| `parties` | `stats-parties.timer` | quarterly, 16th 04:40 | A15: CHES trend file + V-Party (RData via `~/.local/share/stats-py`, pyreadr) linked with Party Facts into `wo_party`/`wo_party_position` |
| `wikipolls` | `stats-wikipolls.timer` | daily 08:10 | A16: Wikipedia poll tables (NL seats, Trump approval, Lee Jae-myung approval, KR party polls); wikitext snapshot per revision |
| `gtrends` | `stats-gtrends.timer` | daily 21:45 | A16: Google Trends trending-now RSS for NL, US, KR |
| `gdelt` | `stats-gdelt.timer` | nightly 02:30 | A16: GDELT DOC timelinevol, 10 countries × 12 themes, last 30 days, ≥5.5 s between requests |
| `eurobarometer` | `stats-eurobarometer.timer` | 20th of the month 05:35 | A16: Standard Eurobarometer volume A xlsx (QA3/QA4/QA5 most important issues, trust in institutions); new waves only |
| `whr` | `stats-whr.timer` | 21st of the month 05:10 | A16: World Happiness Report Figure 2.1 (life evaluation and components, 2011→) |
| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx |
| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer |
| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) |
| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped |
| source | timer | schedule (Europe/Amsterdam) | what |
| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) |
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) |
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) |
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries |
| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries |
| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields |
| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) |
| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags |
| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset |
| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) |
| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) |
| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) |
| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year |
| `eponline` | `stats-eponline.timer` | 3rd of the month 05:20 | EP-Online monthly total file (RVO energy labels): homes per label for NL, Gouda and Gouda PC4s; aggregates only. Needs `EP_ONLINE_API_KEY` in `~/.config/stats/ep-online.env` (mode 600), which the runner exports for this source only |
| `elections` | `stats-elections.timer` | 15th of the month 04:50 | A14: Kiesraad Tweede Kamer results 2010→ (data.overheid.nl CSV + EML zips read with HTTP ranges; national, municipality, Gouda polling stations), CBS 37278 (1918–2006), MIT US presidential by state. Finished elections are skipped unless `--force` |
| `parties` | `stats-parties.timer` | quarterly, 16th 04:40 | A15: CHES trend file + V-Party (RData via `~/.local/share/stats-py`, pyreadr) linked with Party Facts into `wo_party`/`wo_party_position` |
| `wikipolls` | `stats-wikipolls.timer` | daily 08:10 | A16: Wikipedia poll tables (NL seats, Trump approval, Lee Jae-myung approval, KR party polls); wikitext snapshot per revision |
| `gtrends` | `stats-gtrends.timer` | daily 21:45 | A16: Google Trends trending-now RSS for NL, US, KR |
| `gdelt` | `stats-gdelt.timer` | nightly 02:30 | A16: GDELT DOC timelinevol, 10 countries × 12 themes, last 30 days, ≥5.5 s between requests |
| `eurobarometer` | `stats-eurobarometer.timer` | 20th of the month 05:35 | A16: Standard Eurobarometer volume A xlsx (QA3/QA4/QA5 most important issues, trust in institutions); new waves only |
| `whr` | `stats-whr.timer` | 21st of the month 05:10 | A16: World Happiness Report Figure 2.1 (life evaluation and components, 2011→) |
| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx |
| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer |
| `jobs` | `stats-jobs.timer` | Thursdays 06:55 | Jobs & cost of living: OECD SDMX (average/minimum wages USD PPP, hours, 50+ h weeks, temporary/part-time shares, long-term unemployment, monthly youth unemployment, EAG graduate employment + earnings premium, house price-to-income/-rent, comparative price levels USA = 100), Economist Big Mac index (CC BY 4.0), derived US openings per unemployed (run after `fred`). Eurostat/World Bank/FRED jobs series run with their own timers |
| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) |
| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped |
Each timer starts `stats-collector@<source>.service`, which runs
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: jobs & cost of living (OECD wages/hours/LFS/EAG/house prices/price levels, Big Mac index, JOLTS ratio), Thursdays
[Timer]
OnCalendar=Thu *-*-* 06:55:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@jobs.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Radio tracker: refresh ISS TLE and passes over Gouda
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo \"== $$(date -Is) iss\" >> %h/.local/state/radio.log; exec /usr/bin/sudo -n docker exec --env-file %h/.config/radio.env personal-website-app-1 bun scripts/radio.ts iss >> %h/.local/state/radio.log 2>&1"

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily ISS pass refresh for the radio tracker
[Timer]
OnCalendar=*-*-* 04:15:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Radio tracker: ntfy reminders 10 min before nets and ISS passes
[Service]
Type=oneshot
ExecStart=/bin/sh -c "exec /usr/bin/sudo -n docker exec --env-file %h/.config/radio.env personal-website-app-1 bun scripts/radio.ts remind >> %h/.local/state/radio.log 2>&1"

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Radio tracker reminders, every minute
[Timer]
OnCalendar=*:*:00
AccuracySec=5s
[Install]
WantedBy=timers.target

81
docs/plant-tracker.md Normal file
View 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}] }`.

33
docs/radio-tracker.md Normal file
View File

@@ -0,0 +1,33 @@
# Radio tracker (/trackers/radio)
Private (owner-only, 404 for everyone else) tracker for Opa Chiel's scanner hobby.
- **Frequencies** (`radio.frequencies`): freq in Hz, mode, band (marine/air/ham/pmr/other), name,
location, notes, marine channel, tags. Seeded from Opa's lists (airband banks 4-6, marine banks
7-10) and the extras in his notes (Haagse ronde, KLM ronde, ISS downlink, ADS-B).
- **Scanner banks** (`radio.channels`): Uniden UBC125XLT layout, 10 banks x 50 channels.
`/trackers/radio/banks.csv` exports all 500 channels in BC125AT_SS column order.
- **Nets** (`radio.nets`): days (1 = Mon .. 7 = Sun) and "HH:MM" times in Europe/Amsterdam.
- **ISS passes** (`radio.iss_passes`, `radio.iss_tle`): computed in `scripts/radio.ts iss` with
satellite.js from the Celestrak TLE (CATNR 25544) for Gouda (52.011 N, 4.711 E), 5 days ahead.
- **Log** (`radio.log`): what was heard, linked to a frequency.
## Jobs (systemd user timers on stan-ovh, copies in deploy/systemd)
- `radio-iss.timer`: daily 04:15 Amsterdam, `bun scripts/radio.ts iss` in the app container.
- `radio-remind.timer`: every minute, `bun scripts/radio.ts remind`. Sends an ntfy push
10 minutes before each net with `remind` on and each ISS pass with max elevation >= 10 deg.
`radio.reminders` makes each reminder fire once.
- Both read `~/.config/radio.env` (mode 600): `RADIO_NTFY_URL` (ntfy topic URL), optional
`RADIO_NTFY_TOKEN`. Log: `~/.local/state/radio.log`.
- `bun scripts/radio.ts upcoming` prints the next 24 h of reminders without sending.
## Quiet hours
Reminders whose send time (event minus 10 min) falls in 00:00-08:00 Europe/Amsterdam are skipped. Override with `RADIO_QUIET_HOURS=HH:MM-HH:MM` (or `off`) in `~/.config/radio.env`; the page shows the code default (`QUIET_HOURS` in `src/lib/radio/index.ts`). `bun scripts/radio.ts upcoming` marks skipped reminders as QUIET-SKIP.
## Reminders and banks (26 Oct 2026)
Reminders are on only for live events worth tuning into: ISS passes >= 10 deg and the Haagse ronde. The routine coastguard broadcasts (weather 13:05/19:05, nav warnings 17:30) and the tentative KLM ronde (Sun 12:30, 3785 kHz LSB, from one observation) are off.
Banks 1-3 were filled by `scripts/radio-banks-1-3.ts` (idempotent; run once in the app container): bank 1 ISS + APRS, bank 2 calling channels + Haagse ronde, bank 3 FM repeaters around Gouda from the RDI list, cross-checked with Wikipedia.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
CREATE TABLE "ai"."provider_setup_entries" (
"id" serial PRIMARY KEY,
"provider_id" integer NOT NULL,
"section" text NOT NULL,
"name" text NOT NULL,
"note" text,
"scope" text,
"link" text,
"body" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "provider_setup_entries_section_check" CHECK ("section" IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')),
CONSTRAINT "provider_setup_entries_scope_check" CHECK ("scope" IS NULL OR "scope" IN ('global','per-repo','app')),
CONSTRAINT "provider_setup_entries_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE TABLE "ai"."provider_setup_providers" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"products" text,
"color" text DEFAULT 'zinc' NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "provider_setup_providers_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "provider_setup_providers_color_check" CHECK ("color" IN ('orange','green','blue','violet','pink','sky','amber','zinc'))
);
--> statement-breakpoint
CREATE INDEX "provider_setup_entries_provider_idx" ON "ai"."provider_setup_entries" ("provider_id","section","sort_order");--> statement-breakpoint
CREATE UNIQUE INDEX "provider_setup_providers_name_key" ON "ai"."provider_setup_providers" ("name");--> statement-breakpoint
ALTER TABLE "ai"."provider_setup_entries" ADD CONSTRAINT "provider_setup_entries_JxI1C1Zk458j_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai"."provider_setup_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
--> statement-breakpoint
-- Seed the provider cards (owner edits them on /trackers/ai/setup). Entries are added there.
INSERT INTO "ai"."provider_setup_providers" ("name", "products", "color", "sort_order") VALUES
('Claude', 'claude.ai + Claude Code', 'orange', 10),
('ChatGPT', 'ChatGPT + Codex', 'green', 20),
('Gemini', 'Gemini + Gemini CLI', 'blue', 30),
('Grok', 'Grok + Grok Bot', 'violet', 40),
('Copilot', 'GitHub Copilot', 'sky', 50)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
-- Same privacy as the rest of schema ai: app role only (default privileges already cover new
-- tables; granted explicitly too). Grants only run on prod (migrations as `personal`).
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON "ai"."provider_setup_providers", "ai"."provider_setup_entries" TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
CREATE TABLE "cs"."history_fetches" (
"item_id" integer,
"source" text,
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
"points" integer DEFAULT 0 NOT NULL,
"first_day" date,
"error" text,
CONSTRAINT "history_fetches_pkey" PRIMARY KEY("item_id","source")
);
--> statement-breakpoint
ALTER TABLE "cs"."history_fetches" ADD CONSTRAINT "history_fetches_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"wildlife:refresh": "scripts/wildlife-refresh.sh",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"cs-prices:daily": "bun scripts/cs-prices.ts daily",
"house:worker": "bun scripts/house-worker.ts",
"house": "bun scripts/house-listing.ts",
"house:add": "bun scripts/house-listing.ts add",
@@ -116,6 +117,7 @@
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9",
"satellite.js": "^7.1.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4"

399
scripts/cs-history.ts Normal file
View File

@@ -0,0 +1,399 @@
/**
* CS2 price history backfill into cs.daily_prices, under separate `*-history` streams so it never
* mixes with the daily scrape. Resumable (cs.history_fetches) and throttled; run in the background:
*
* bun scripts/cs-history.ts fx ECB USD→EUR for every day since 2013 (Frankfurter),
* weekends/holidays carry the last rate forward
* bun scripts/cs-history.ts steam [max] Steam Community Market daily median sale price +
* volume since listing, read from the public market
* listing page (no login). One page holds every wear /
* StatTrak / Souvenir sibling, so it is ~1 request per
* skin, paced ~9 s apart with backoff on 429.
* bun scripts/cs-history.ts csfloat-queue print CSFloat targets (NDJSON) for an off-box fetcher
* bun scripts/cs-history.ts csfloat-import read CSFloat graph NDJSON on stdin and store it
* bun scripts/cs-history.ts status coverage per history stream
*
* Steam prices arrive in the page's wallet currency (EUR here, `ecurrency` 3) and are stored as
* is; USD streams (CSFloat) are converted per day with cs.fx_rates on read.
*/
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
const mode = process.argv[2] ?? 'status';
const ua = 'Mozilla/5.0';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const log = (...args: unknown[]) => console.log(new Date().toISOString().slice(0, 19), ...args);
const streams = {
steam: {
source: 'steam-market',
kind: 'sale-median-history',
label: 'Steam Market daily median sale (history)'
},
csfloat: {
source: 'csfloat',
kind: 'sale-avg-history',
label: 'CSFloat daily average sale (history)'
}
};
async function streamId(source: string, currency: string, kind: string, label: string) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs.price_streams (source, currency, kind, label)
VALUES (${source}, ${currency}, ${kind}, ${label})
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label RETURNING id`;
return row.id;
}
type Day = { day: string; cents: number; volume: number | null };
async function store(stream: number, item: number, days: Day[]) {
for (let offset = 0; offset < days.length; offset += 5000) {
const chunk = days.slice(offset, offset + 5000);
await sql`
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
SELECT ${item}::int, ${stream}::smallint, day, price_cents, volume
FROM unnest(${chunk.map((d) => d.day)}::date[], ${chunk.map((d) => d.cents)}::int[],
${chunk.map((d) => d.volume)}::int[]) AS x(day, price_cents, volume)
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
}
async function mark(item: number, source: string, days: Day[], error: string | null = null) {
await sql`
INSERT INTO cs.history_fetches (item_id, source, fetched_at, points, first_day, error)
VALUES (${item}, ${source}, now(), ${days.length}, ${days[0]?.day ?? null}, ${error})
ON CONFLICT (item_id, source) DO UPDATE SET fetched_at = now(), points = EXCLUDED.points,
first_day = EXCLUDED.first_day, error = EXCLUDED.error`;
}
async function fx() {
const from = '2013-01-01';
const to = new Date().toISOString().slice(0, 10);
let rates: Record<string, { EUR: number }> | undefined;
for (const url of [
`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=EUR`,
`https://api.frankfurter.app/${from}..${to}?from=USD&to=EUR`
]) {
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (response.ok) {
rates = ((await response.json()) as { rates: typeof rates }).rates;
break;
}
}
if (!rates) throw new Error('No FX history');
const days: string[] = [];
const values: number[] = [];
let last: number | null = null;
for (let t = Date.parse(from); t <= Date.parse(to); t += 86_400_000) {
const day = new Date(t).toISOString().slice(0, 10);
last = rates[day]?.EUR ?? last;
if (last != null) {
days.push(day);
values.push(last);
}
}
// Existing rows (the daily scrape's own rate) win.
const result = await sql`
INSERT INTO cs.fx_rates (day, usd_eur)
SELECT * FROM unnest(${days}::date[], ${values}::numeric[]) ON CONFLICT (day) DO NOTHING`;
log(
`FX: ${Object.keys(rates).length} ECB fixings, ${result.count} days inserted (${days[0]}..${to})`
);
}
/** Every pricehistory query embedded in the listing page's SSR state. */
function pageHistories(html: string) {
const match = html.match(
/<script type="application\/json" id="valve-ssr-data"[^>]*>([\s\S]*?)<\/script>/
);
if (!match) return null;
const found = new Map<
string,
{ currency: number; prices: { time: number; price_median: number; purchases: number }[] }
>();
const walk = (node: unknown): void => {
if (typeof node === 'string') {
if (node.length > 2 && (node[0] === '{' || node[0] === '[')) {
try {
walk(JSON.parse(node));
} catch {
/* not JSON */
}
}
return;
}
if (Array.isArray(node)) return node.forEach(walk);
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>;
const key = record.queryKey;
if (
Array.isArray(key) &&
key[0] === 'market' &&
key[1] === 'pricehistory' &&
key[2] === 730
) {
const data = (record.state as { data?: { ecurrency?: number; prices?: [] } } | undefined)
?.data;
if (data && Array.isArray(data.prices))
found.set(String(key[3]), { currency: data.ecurrency ?? 0, prices: data.prices });
}
for (const value of Object.values(record)) walk(value);
}
};
walk(JSON.parse(match[1]));
return found;
}
/** Hourly points (last month) collapse to one row per UTC day, volume-weighted. */
function daily(prices: { time: number; price_median: number; purchases: number }[]): Day[] {
const byDay = new Map<string, { sum: number; weight: number; volume: number }>();
for (const p of prices) {
if (!Number.isFinite(p.price_median) || p.price_median <= 0) continue;
const day = new Date(p.time * 1000).toISOString().slice(0, 10);
const weight = Math.max(1, p.purchases || 0);
const entry = byDay.get(day) ?? { sum: 0, weight: 0, volume: 0 };
entry.sum += p.price_median * weight;
entry.weight += weight;
entry.volume += p.purchases || 0;
byDay.set(day, entry);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, e]) => ({ day, cents: Math.round((e.sum / e.weight) * 100), volume: e.volume }))
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000);
}
async function curlPage(url: string) {
const proc = Bun.spawn(
['curl', '-sL', '--compressed', '--max-time', '60', '-A', ua, '-w', '\n%{http_code}', url],
{ stdout: 'pipe', stderr: 'ignore' }
);
const out = await new Response(proc.stdout).text();
await proc.exited;
const cut = out.lastIndexOf('\n');
return { status: Number(out.slice(cut + 1)) || 0, body: out.slice(0, cut) };
}
const kindOrder = [
'knife',
'gloves',
'weapon',
'container',
'agent',
'key',
'music-kit',
'patch',
'collectible',
'sticker',
'graffiti',
'charm',
'other'
];
async function steam(max: number) {
const currencies: Record<number, string> = { 1: 'USD', 3: 'EUR' };
const ids: Record<string, number> = {};
const idFor = async (currency: string) =>
(ids[currency] ??= await streamId(
streams.steam.source,
currency,
streams.steam.kind,
streams.steam.label
));
await idFor('EUR');
// One representative unfetched phase-less config per skin, popular kinds/skins first.
const queue = await sql<{ skin_id: string; item_id: number; hash: string }[]>`
WITH todo AS (
SELECT i.id, i.skin_id, i.market_hash_name, s.kind,
coalesce((SELECT max(w.volume) FROM cs.price_windows w
WHERE w.item_id = i.id AND w.source = 'skinport' AND w.span = '90d'), 0) AS vol
FROM cs.items i JOIN cs.skins s ON s.id = i.skin_id
WHERE i.phase IS NULL AND NOT EXISTS (SELECT 1 FROM cs.history_fetches f
WHERE f.item_id = i.id AND f.source = 'steam')
), skins AS (
SELECT skin_id, kind, sum(vol) AS vol FROM todo GROUP BY 1, 2
)
SELECT DISTINCT ON (s.vol, s.skin_id) s.skin_id, t.id AS item_id, t.market_hash_name AS hash
FROM skins s JOIN todo t ON t.skin_id = s.skin_id
ORDER BY s.vol DESC, s.skin_id, t.vol DESC, t.id`;
const kinds = new Map(
(await sql<{ id: string; kind: string }[]>`SELECT id, kind FROM cs.skins`).map((r) => [
r.id,
r.kind
])
);
const rank = (skin: string) => {
const index = kindOrder.indexOf(kinds.get(skin) ?? 'other');
return index < 0 ? kindOrder.length : index;
};
const ordered = queue
.map((row, index) => ({ ...row, index }))
.sort((a, b) => rank(a.skin_id) - rank(b.skin_id) || a.index - b.index);
log(`Steam: ${ordered.length} skins with unfetched configs`);
let delay = 9000;
let strikes = 0;
let requests = 0;
let stored = 0;
for (let i = 0; i < ordered.length; i++) {
const row = ordered[i];
if (requests >= max) break;
// Skip when an earlier page already covered this skin's representative.
const [done] =
await sql`SELECT 1 FROM cs.history_fetches WHERE item_id = ${row.item_id} AND source = 'steam'`;
if (done) continue;
const siblings = await sql<{ id: number; hash: string }[]>`
SELECT id, market_hash_name AS hash FROM cs.items WHERE skin_id = ${row.skin_id} AND phase IS NULL`;
const byHash = new Map(siblings.map((s) => [s.hash, s.id]));
const url = `https://steamcommunity.com/market/listings/730/${encodeURIComponent(row.hash)}`;
requests++;
// curl with a bare UA and no Accept-Language: Steam answers Bun's fetch, and any request
// with Accept-Language, with 429 but serves this normally.
let page: { status: number; body: string };
try {
page = await curlPage(url);
} catch (cause) {
log(`net error ${row.hash}: ${String(cause)}`);
await sleep(60_000);
i--; // retry the same skin
continue;
}
if (page.status === 429 || page.status >= 500 || page.status === 0) {
strikes++;
delay = Math.min(30_000, delay + 1000);
const pause = Math.min(30 * 60_000, 60_000 * strikes);
log(
`HTTP ${page.status} (strike ${strikes}), pausing ${pause / 1000}s, delay now ${delay / 1000}s`
);
await sleep(pause);
i--; // retry the same skin
continue;
}
strikes = 0;
if (page.status !== 200) {
await mark(row.item_id, 'steam', [], `HTTP ${page.status}`);
await sleep(delay);
continue;
}
const histories = pageHistories(page.body);
if (!histories) {
await mark(row.item_id, 'steam', [], 'no ssr data');
log(`no SSR data for ${row.hash}`);
await sleep(delay);
continue;
}
let pageRows = 0;
for (const [hash, history] of histories) {
const item =
byHash.get(hash) ??
(
await sql<{ id: number }[]>`SELECT id FROM cs.items WHERE market_hash_name = ${hash}
AND phase IS NULL`
)[0]?.id;
if (!item) continue;
const currency = currencies[history.currency];
if (!currency) {
await mark(item, 'steam', [], `currency ${history.currency}`);
continue;
}
const days = daily(history.prices);
if (days.length) await store(await idFor(currency), item, days);
await mark(item, 'steam', days);
pageRows += days.length;
}
if (!histories.has(row.hash)) await mark(row.item_id, 'steam', [], 'not on page');
stored += pageRows;
if (requests % 25 === 0 || requests <= 3)
log(
`#${requests} ${row.hash}: ${histories.size} configs, ${pageRows} days (total ${stored})`
);
await sleep(delay + Math.random() * 3000);
}
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${stored}
WHERE source = ${streams.steam.source} AND kind = ${streams.steam.kind}`;
log(`Steam done: ${requests} requests, ${stored} day rows`);
}
/** Targets for the CSFloat fetcher: phase-less configs plus Doppler phases (paint index added by
* the fetcher), most traded first, skipping ones already fetched. */
async function csfloatQueue() {
const rows = await sql<{ item_id: number; hash: string; phase: string | null; vol: number }[]>`
SELECT i.id AS item_id, i.market_hash_name AS hash, i.phase,
coalesce((SELECT max(w.volume) FROM cs.price_windows w WHERE w.item_id = i.id
AND w.span = '90d'), 0)::int AS vol
FROM cs.items i
WHERE NOT EXISTS (SELECT 1 FROM cs.history_fetches f WHERE f.item_id = i.id AND f.source = 'csfloat')
ORDER BY vol DESC, i.id`;
for (const row of rows) process.stdout.write(JSON.stringify(row) + '\n');
}
async function csfloatImport() {
const stream = await streamId(
streams.csfloat.source,
'USD',
streams.csfloat.kind,
streams.csfloat.label
);
let items = 0;
let rows = 0;
let buffer = '';
const handle = async (line: string) => {
if (!line.trim()) return;
const entry = JSON.parse(line) as {
item_id: number;
error?: string;
graph?: { day: string; avg_price: number; count: number }[];
};
const days = (entry.graph ?? [])
.map((g) => ({
day: g.day.slice(0, 10),
cents: Math.round(g.avg_price),
volume: g.count ?? null
}))
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000)
.sort((a, b) => a.day.localeCompare(b.day));
if (days.length) await store(stream, entry.item_id, days);
await mark(entry.item_id, 'csfloat', days, entry.error ?? null);
items++;
rows += days.length;
};
for await (const chunk of Bun.stdin.stream()) {
buffer += new TextDecoder().decode(chunk);
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) await handle(line);
}
await handle(buffer);
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${rows} WHERE id = ${stream}`;
log(`CSFloat import: ${items} configs, ${rows} day rows`);
}
async function status() {
const rows = await sql`
SELECT st.source, st.kind, st.currency, count(*)::int AS rows, count(DISTINCT dp.item_id)::int AS configs,
min(dp.day)::text AS first_day, max(dp.day)::text AS last_day
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
WHERE st.kind LIKE '%-history' GROUP BY 1, 2, 3 ORDER BY 1`;
console.table(rows);
const [cov] = await sql`
WITH h AS (SELECT dp.item_id, count(DISTINCT dp.day) AS days FROM cs.daily_prices dp
JOIN cs.price_streams st ON st.id = dp.stream_id WHERE st.kind LIKE '%-history' GROUP BY 1)
SELECT (SELECT count(*) FROM cs.items)::int AS configs, count(*)::int AS with_history,
count(*) FILTER (WHERE days > 30)::int AS over_30_days FROM h`;
console.log(cov);
console.table(
await sql`SELECT source, count(*)::int AS fetched, sum(points)::int AS points,
count(*) FILTER (WHERE points > 0)::int AS with_points, max(fetched_at) AS last
FROM cs.history_fetches GROUP BY 1`
);
}
try {
if (mode === 'fx') await fx();
else if (mode === 'steam') await steam(Number(process.argv[3] ?? Infinity));
else if (mode === 'csfloat-queue') await csfloatQueue();
else if (mode === 'csfloat-import') await csfloatImport();
else if (mode === 'status') await status();
else throw new Error('Usage: cs-history.ts fx|steam [max]|csfloat-queue|csfloat-import|status');
} finally {
await sql.end();
}

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

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

View File

@@ -188,8 +188,9 @@ try {
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
const AI_TABLES = 3;
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
const AI_TABLES = 5;
const [ai] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
@@ -286,6 +287,87 @@ 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');
// Radio tracker (owner-only): 7 = frequencies, channels, nets, iss_passes, iss_tle, log,
// reminders; the migration seeds Opa's frequencies.
const RADIO_TABLES = 7;
const [radio] = 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', 'radio', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'radio' and c.relkind = 'r'
`;
if (
Number(radio.tables) !== RADIO_TABLES ||
Number(radio.app_rw) !== RADIO_TABLES ||
Number(radio.ro_read) !== 0 ||
radio.public_usage
)
throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`);
const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`;
if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies');
// 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 +377,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), radio: ${radio.tables} tables (${radioFreqs} frequencies), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

View File

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

178
scripts/radio-banks-1-3.ts Normal file
View File

@@ -0,0 +1,178 @@
// One-off data fill for scanner banks 1-3 (ham + ISS). Idempotent: reuses a frequency with the
// same freq_hz + name, and only fills empty channels.
import postgres from 'postgres';
const c = postgres(process.env.DB_URL!, { prepare: false, onnotice: () => {} });
const ARISS = 'ARISS https://www.ariss.org/contact-the-iss.html + status page (7 Oct 2026)';
const RDI =
'RDI repeater list via elinek.nl, cross-checked with nl.wikipedia Lijst van amateurrelaiszenders';
type F = {
ch: number;
hz: number;
mode: string;
name: string;
tag: string;
loc?: string;
notes: string;
tags: string[];
src: string;
existing?: number;
};
const rows: F[] = [
{
ch: 1,
hz: 145_800_000,
mode: 'NFM',
name: 'ISS voice downlink (ARISS)',
tag: 'ISS VOICE/SSTV',
notes: '',
tags: [],
src: '',
existing: 330
},
{
ch: 2,
hz: 437_800_000,
mode: 'FM',
name: 'ISS FM repeater downlink',
tag: 'ISS RPT DN',
notes:
'Cross-band voice repeater (NA1SS): uplink 145.990 MHz CTCSS 67 Hz, downlink 437.800. Allow ±10 kHz Doppler.',
tags: ['iss', 'satellite', '70cm'],
src: ARISS
},
{
ch: 3,
hz: 437_825_000,
mode: 'FM',
name: 'ISS APRS digipeater (UHF, active)',
tag: 'ISS APRS UHF',
notes:
'APRS 1200 bd packet; ARISS status says APRS is currently active here (RS0ISS). Decode with software; on the scanner you hear bursts.',
tags: ['iss', 'satellite', 'aprs', '70cm'],
src: ARISS
},
{
ch: 4,
hz: 145_825_000,
mode: 'FM',
name: 'ISS APRS digipeater (VHF)',
tag: 'ISS APRS VHF',
notes: 'Usual worldwide ISS packet frequency (1200 bd APRS); currently moved to 437.825.',
tags: ['iss', 'satellite', 'aprs', '2m'],
src: ARISS
},
{
ch: 5,
hz: 437_550_000,
mode: 'FM',
name: 'ISS SSTV downlink (events)',
tag: 'ISS SSTV UHF',
notes: 'SSTV events (Robot36), announced by ARISS. Decode with an SSTV app.',
tags: ['iss', 'satellite', 'sstv', '70cm'],
src: ARISS
},
{
ch: 6,
hz: 144_800_000,
mode: 'FM',
name: 'APRS (Europe, terrestrial)',
tag: 'APRS EU',
notes: 'IARU Region 1 APRS frequency, 1200 bd packet.',
tags: ['aprs', '2m'],
src: 'IARU Region 1 VHF band plan'
},
{
ch: 51,
hz: 145_500_000,
mode: 'FM',
name: '2 m FM calling',
tag: '2M CALL',
notes: 'FM simplex calling channel (V40).',
tags: ['calling', '2m'],
src: 'IARU Region 1 VHF band plan'
},
{
ch: 52,
hz: 433_500_000,
mode: 'FM',
name: '70 cm FM calling',
tag: '70CM CALL',
notes: 'FM simplex calling channel (U280).',
tags: ['calling', '70cm'],
src: 'IARU Region 1 UHF band plan'
},
{
ch: 53,
hz: 145_450_000,
mode: 'NFM',
name: 'Haagse ronde',
tag: 'HAAGSE RONDE',
notes: '',
tags: [],
src: '',
existing: 328
},
...(
[
[
101,
430_225_000,
'PI2SWK',
'Gouda',
'431.825',
'88.5',
'Also pi2swk.nl (Goudse ronde, EchoLink 669316).'
],
[102, 145_750_000, 'PI3RAZ', 'Zoetermeer', '145.150', '88.5', ''],
[103, 145_675_000, 'PI3RTD', 'Rotterdam', '145.075', '88.5', ''],
[104, 430_300_000, 'PI2RTD', 'Rotterdam', '431.900', '88.5', ''],
[105, 430_087_500, 'PI1DFT', 'Delft', '431.6875', '88.5', 'Same club as WebSDR PI1DFT.'],
[106, 430_350_000, 'PI2HGL', 'Den Haag', '431.950', '88.5', ''],
[107, 430_050_000, 'PI2LDN', 'Leiden', '431.650', '88.5', ''],
[
108,
145_575_000,
'PI3UTR',
'IJsselstein',
'144.975',
'77',
'Regional, wide coverage (Utrecht/Hilversum TX).'
]
] as const
).map(([ch, hz, call, loc, inp, tone, extra]) => ({
ch,
hz,
mode: 'FM',
name: `${call} repeater ${loc}`,
tag: `${call} ${loc}`.slice(0, 16),
loc,
notes: `FM repeater output; input ${inp} MHz, CTCSS ${tone} Hz. ${extra}`.trim(),
tags: ['repeater', hz < 200_000_000 ? '2m' : '70cm'],
src: RDI
}))
];
let added = 0,
set = 0;
for (const r of rows) {
let id = r.existing;
if (!id) {
const [f] =
await c`SELECT id FROM radio.frequencies WHERE freq_hz = ${r.hz} AND name = ${r.name}`;
id = f?.id;
if (!id) {
[{ id }] =
await c`INSERT INTO radio.frequencies ${c({ freq_hz: r.hz, mode: r.mode, band: 'ham', name: r.name, location: r.loc ?? null, notes: r.notes, tags: r.tags, source: r.src })} RETURNING id`;
added++;
}
}
const ins =
await c`INSERT INTO radio.channels ${c({ channel: r.ch, frequency_id: id, alpha_tag: r.tag.slice(0, 16) })} ON CONFLICT (channel) DO NOTHING`;
set += ins.count;
}
console.log(JSON.stringify({ frequenciesAdded: added, channelsSet: set }));
console.log(
JSON.stringify(
await c`SELECT (SELECT count(*)::int FROM radio.frequencies) freqs, (SELECT count(*)::int FROM radio.channels) chans, (SELECT count(*)::int FROM radio.channels WHERE channel <= 150) b123`
)
);
await c.end();

275
scripts/radio.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* Radio tracker jobs (/trackers/radio). Runs inside the app container like cs-prices:
*
* bun scripts/radio.ts iss fetch the ISS TLE from Celestrak, compute the next 5 days of
* passes over Gouda into radio.iss_passes (daily timer)
* bun scripts/radio.ts remind ntfy reminder 10 minutes before each net (remind = true) and
* each ISS pass with max elevation >= 10 deg (every-minute timer)
* bun scripts/radio.ts upcoming print what the next 24 h of reminders would be (no sending)
*
* Reminders go to RADIO_NTFY_URL (an ntfy topic URL, optional RADIO_NTFY_TOKEN), passed in with
* `docker exec --env-file ~/.config/radio.env`. radio.reminders makes every reminder fire once.
* RADIO_QUIET_HOURS (default 00:00-08:00, "off" to disable) skips reminders sent in that window.
*/
import postgres from 'postgres';
import * as sat from 'satellite.js';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
QUIET_HOURS,
REMIND_MINUTES,
compass,
fmtTime,
formatFreq,
inQuiet,
occurrences,
parseQuiet
} from '../src/lib/radio/index';
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] ?? 'remind';
// Quiet hours (Amsterdam): reminders whose send time falls inside are skipped. "off" disables.
const QUIET = parseQuiet(process.env.RADIO_QUIET_HOURS ?? QUIET_HOURS);
const sendAt = (at: Date) => new Date(at.getTime() - REMIND_MINUTES * 60_000);
const quiet = (at: Date) => inQuiet(sendAt(at), QUIET);
const PAGE = 'https://stanrunge.dev/trackers/radio';
// Gouda (approx), 0 m.
const OBSERVER = {
latitude: sat.degreesToRadians(52.011),
longitude: sat.degreesToRadians(4.711),
height: 0
};
const TLE_URL = 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle';
const DAYS = 5;
const STEP_S = 10;
const KM_PER_AU = 149_597_870.7;
async function fetchTle() {
const res = await fetch(TLE_URL, {
headers: { 'User-Agent': 'stanrunge.dev radio-tracker/1.0 (daily ISS passes)' },
signal: AbortSignal.timeout(20_000)
});
if (!res.ok) throw new Error(`Celestrak ${res.status}`);
const lines = (await res.text())
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter(Boolean);
const i = lines.findIndex((l) => l.startsWith('1 25544'));
if (i < 0 || !lines[i + 1]?.startsWith('2 25544')) throw new Error('No ISS TLE in the response');
return {
name: (i > 0 ? lines[i - 1] : 'ISS (ZARYA)').trim(),
line1: lines[i],
line2: lines[i + 1]
};
}
function look(satrec: sat.SatRec, at: Date) {
const pv = sat.propagate(satrec, at);
if (!pv || typeof pv.position === 'boolean' || !pv.position) return null;
const gmst = sat.gstime(at);
const la = sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(pv.position, gmst));
return {
el: sat.radiansToDegrees(la.elevation),
az: sat.radiansToDegrees(la.azimuth),
eci: pv.position,
gmst
};
}
/** ISS in sunlight while the sun is >= 6 deg below the observer's horizon (naked-eye visible). */
function sunlitInDarkSky(at: Date, eci: sat.EciVec3<number>, gmst: number) {
const jd = sat.jday(at);
const { rsun } = sat.sunPos(jd);
const sunKm = { x: rsun.x * KM_PER_AU, y: rsun.y * KM_PER_AU, z: rsun.z * KM_PER_AU };
const sunEl = sat.radiansToDegrees(
sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(sunKm, gmst)).elevation
);
return sunEl < -6 && sat.shadowFraction(rsun, eci) < 0.5;
}
type Pass = {
start_at: Date;
max_at: Date;
end_at: Date;
max_el: number;
start_az: number;
max_az: number;
end_az: number;
sunlit: boolean;
};
function passes(satrec: sat.SatRec, from: Date, days: number): Pass[] {
const out: Pass[] = [];
const end = from.getTime() + days * 86_400_000;
let cur: Pass | null = null;
let prevEl = -90;
for (let t = from.getTime(); t <= end; t += STEP_S * 1000) {
const at = new Date(t);
const l = look(satrec, at);
if (!l) continue;
if (l.el >= 0 && prevEl < 0) {
cur = {
start_at: at,
max_at: at,
end_at: at,
max_el: l.el,
start_az: l.az,
max_az: l.az,
end_az: l.az,
sunlit: false
};
}
if (cur && l.el >= 0) {
if (l.el > cur.max_el) Object.assign(cur, { max_el: l.el, max_at: at, max_az: l.az });
if (!cur.sunlit && l.el >= ISS_MIN_EL && sunlitInDarkSky(at, l.eci, l.gmst))
cur.sunlit = true;
cur.end_at = at;
cur.end_az = l.az;
}
if (cur && l.el < 0) {
// Skip a pass already in progress at the start of the window.
if (cur.start_at.getTime() > from.getTime()) out.push(cur);
cur = null;
}
prevEl = l.el;
}
return out;
}
async function refreshIss() {
const tle = await fetchTle();
const satrec = sat.twoline2satrec(tle.line1, tle.line2);
const epoch = new Date((satrec.jdsatepoch - 2440587.5) * 86_400_000);
const now = new Date();
const list = passes(satrec, now, DAYS).filter((p) => p.max_el >= 1);
await sql.begin(async (tx) => {
await tx`INSERT INTO radio.iss_tle (id, name, line1, line2, epoch, fetched_at)
VALUES (1, ${tle.name}, ${tle.line1}, ${tle.line2}, ${epoch}, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, line1 = EXCLUDED.line1,
line2 = EXCLUDED.line2, epoch = EXCLUDED.epoch, fetched_at = now()`;
// Future passes are replaced by the fresh prediction; past ones are kept a week.
await tx`DELETE FROM radio.iss_passes WHERE start_at > now() OR end_at < now() - interval '7 days'`;
if (list.length)
await tx`INSERT INTO radio.iss_passes ${tx(list)} ON CONFLICT (start_at) DO NOTHING`;
});
const good = list.filter((p) => p.max_el >= ISS_MIN_EL).length;
console.log(
`${now.toISOString()} iss: TLE epoch ${epoch.toISOString()}, ${list.length} passes in ${DAYS} days (${good} >= ${ISS_MIN_EL} deg)`
);
}
async function ntfy(title: string, message: string, tags: string[]) {
const target = process.env.RADIO_NTFY_URL;
if (!target) {
console.log(`no RADIO_NTFY_URL; would send: ${title} | ${message}`);
return false;
}
const url = new URL(target);
const res = await fetch(url.origin, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(process.env.RADIO_NTFY_TOKEN
? { authorization: `Bearer ${process.env.RADIO_NTFY_TOKEN}` }
: {})
},
body: JSON.stringify({
topic: url.pathname.replace(/^\/+|\/+$/g, ''),
title,
message,
click: PAGE,
tags,
priority: 3
}),
signal: AbortSignal.timeout(10_000)
}).catch(() => null);
return Boolean(res?.ok);
}
type Due = {
kind: 'net' | 'iss';
refId: number;
at: Date;
title: string;
message: string;
tags: string[];
};
async function due(from: Date, to: Date): Promise<Due[]> {
const out: Due[] = [];
const nets = await sql`SELECT n.id, n.name, n.days, n.times, n.active, n.notes,
f.freq_hz::float8 AS hz, f.mode, f.marine_ch
FROM radio.nets n LEFT JOIN radio.frequencies f ON f.id = n.frequency_id
WHERE n.remind AND n.active`;
for (const n of nets) {
for (const at of occurrences(
{ id: n.id, days: n.days.map(Number), times: n.times },
from,
to
)) {
const freq = n.hz
? `${formatFreq(n.hz)} ${n.mode}${n.marine_ch ? ` (marine ch ${n.marine_ch})` : ''}`
: '';
out.push({
kind: 'net',
refId: n.id,
at,
title: `${n.name} at ${fmtTime(at)}`,
message: [`Starts in ${REMIND_MINUTES} min`, freq].filter(Boolean).join(' · '),
tags: ['radio']
});
}
}
const iss = await sql`SELECT start_at, max_at, end_at, max_el, start_az, max_az, end_az, sunlit
FROM radio.iss_passes WHERE start_at > ${from} AND start_at <= ${to} AND max_el >= ${ISS_MIN_EL}`;
for (const p of iss) {
const mins = Math.round((p.end_at.getTime() - p.start_at.getTime()) / 60_000);
out.push({
kind: 'iss',
refId: 0,
at: p.start_at,
title: `ISS pass at ${fmtTime(p.start_at)}, max ${Math.round(p.max_el)}°`,
message:
`${fmtTime(p.start_at)} ${compass(p.start_az)} → max ${Math.round(p.max_el)}° ${compass(p.max_az)} at ${fmtTime(p.max_at)} → ${compass(p.end_az)} ${fmtTime(p.end_at)} (${mins} min)` +
` · listen on ${formatFreq(ISS_DOWNLINK_HZ)} NFM` +
(p.sunlit ? ' · visible to the eye' : ''),
tags: ['satellite']
});
}
return out.sort((a, b) => a.at.getTime() - b.at.getTime());
}
async function remind() {
const now = new Date();
const list = await due(now, new Date(now.getTime() + REMIND_MINUTES * 60_000));
for (const r of list) {
if (quiet(r.at)) continue;
const [claimed] = await sql`INSERT INTO radio.reminders (kind, ref_id, at)
VALUES (${r.kind}, ${r.refId}, ${r.at}) ON CONFLICT DO NOTHING RETURNING at`;
if (!claimed) continue;
const ok = await ntfy(r.title, r.message, r.tags);
if (!ok) {
// Let the next minute's run try again (it stops once the event has started).
await sql`DELETE FROM radio.reminders WHERE kind = ${r.kind} AND ref_id = ${r.refId} AND at = ${r.at}`;
console.log(`${now.toISOString()} remind FAILED: ${r.title}`);
} else console.log(`${now.toISOString()} reminded: ${r.title} | ${r.message}`);
}
await sql`DELETE FROM radio.reminders WHERE at < now() - interval '30 days'`;
}
try {
if (mode === 'iss') await refreshIss();
else if (mode === 'remind') await remind();
else if (mode === 'upcoming') {
const now = new Date();
console.log(`quiet hours: ${QUIET?.label ?? 'off'} (Europe/Amsterdam, by send time)`);
for (const r of await due(now, new Date(now.getTime() + 86_400_000)))
console.log(
`${quiet(r.at) ? 'QUIET-SKIP' : 'SEND '} at ${fmtTime(sendAt(r.at))} ${r.kind}: ${r.title}`
);
} else throw new Error(`unknown mode ${mode} (iss | remind | upcoming)`);
} finally {
await sql.end();
}

View File

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

View File

@@ -17,6 +17,7 @@
// bun scripts/stats-fetch.ts elections [--force] [--only=kiesraad,cbs,mit] A14
// bun scripts/stats-fetch.ts parties CHES + V-Party + Party Facts (A15)
// bun scripts/stats-fetch.ts wikipolls|eurobarometer [--force]|whr|gtrends|gdelt A16
// bun scripts/stats-fetch.ts jobs [--only=wages,quality,finding,grads,housing,prices,bigmac,jolts,pricelevel]
// bun scripts/stats-fetch.ts normalise-cbs rebuild CBS chart series from the raw rows
import { ensureMeta, sql } from './stats/common';
import { cbs, normaliseCbs } from './stats/cbs';
@@ -43,6 +44,7 @@ import { whr } from './stats/whr';
import { gtrends } from './stats/gtrends';
import { gdelt } from './stats/gdelt';
import { history } from './stats/history';
import { jobs } from './stats/jobs';
const [source, ...args] = process.argv.slice(2);
const flag = (name: string) => args.includes(`--${name}`);
@@ -124,12 +126,15 @@ try {
case 'history':
await history({ only: value('only'), job });
break;
case 'jobs':
await jobs({ only: value('only'), job });
break;
case 'gdelt':
await gdelt({ job, timespan: value('timespan') });
break;
default:
throw new Error(
`Unknown source "${source}" (cbs, eurostat, fred, bis, oecd, statcan, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, normalise-cbs)`
`Unknown source "${source}" (cbs, eurostat, fred, bis, oecd, statcan, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, jobs, normalise-cbs)`
);
}
} finally {

View File

@@ -382,6 +382,14 @@ export const SOURCES = [
key_required: false,
attribution: 'Source: Pew Research Center (2025), via Our World in Data'
},
{
source_id: 'economist',
name: 'The Economist Big Mac index',
base_url: 'https://github.com/TheEconomist/big-mac-data',
licence: 'CC BY 4.0',
key_required: false,
attribution: 'Source: The Economist, Big Mac index (CC BY 4.0)'
},
{
source_id: 'history',
name: 'Curated history and culture notes',
@@ -397,6 +405,7 @@ export const REGIONS: { region_code: string; name: string; kind: string; parent:
{ region_code: 'WLD', name: 'World', kind: 'aggregate', parent: null },
{ region_code: 'EU27', name: 'European Union (27)', kind: 'aggregate', parent: 'WLD' },
{ region_code: 'EA', name: 'Euro area', kind: 'aggregate', parent: 'EU27' },
{ region_code: 'OECD', name: 'OECD members', kind: 'aggregate', parent: 'WLD' },
{ region_code: 'NLD', name: 'Netherlands', kind: 'country', parent: 'EU27' },
{ region_code: 'USA', name: 'United States', kind: 'country', parent: 'WLD' },
{ region_code: 'KOR', name: 'South Korea', kind: 'country', parent: 'WLD' },

View File

@@ -129,7 +129,103 @@ export const EUROSTAT_DATASETS: Dataset[] = [
theme: 'economy',
params: { unit: ['CP_MEUR', 'CLV_PCH_PRE'], na_item: 'B1GQ' }
},
{ code: 'demo_pjan', theme: 'countries', params: { sex: 'T', age: 'TOTAL', unit: 'NR' } }
{ code: 'demo_pjan', theme: 'countries', params: { sex: 'T', age: 'TOTAL', unit: 'NR' } },
// ── Jobs & cost of living (/stats/<place>/jobs) ──
{
code: 'une_rt_m',
theme: 'jobs',
params: { s_adj: 'SA', age: ['TOTAL', 'Y_LT25', 'Y25-74'], unit: 'PC_ACT', sex: 'T' },
geos: [...EU_GEOS, 'US', 'JP']
},
{
code: 'lfsa_urgan',
theme: 'jobs',
params: { unit: 'PC', sex: 'T', age: ['Y15-24', 'Y25-29', 'Y15-74'], citizen: 'TOTAL' }
},
{
code: 'jvs_q_nace2',
theme: 'jobs',
params: { s_adj: 'NSA', nace_r2: 'B-S', sizeclas: 'TOTAL', indic_em: 'JVR' }
},
{
// Employment rate of recent graduates (left education 1–3 years ago), 20–34.
code: 'edat_lfse_24',
theme: 'jobs',
params: { duration: 'Y1-3', isced11: ['ED3-8', 'ED5-8'], age: 'Y20-34', sex: 'T', unit: 'PC' }
},
{
// NEET rate: not employed, not in education or training.
code: 'edat_lfse_20',
theme: 'jobs',
params: {
sex: 'T',
age: ['Y15-29', 'Y15-24'],
training: 'NO_FE_NO_NFE',
wstatus: 'NEMP',
unit: 'PC'
}
},
{
code: 'lfsa_etpgan',
theme: 'jobs',
params: { unit: 'PC', sex: 'T', age: ['Y15-64', 'Y15-24', 'Y25-34'], citizen: 'TOTAL' }
},
{
code: 'lfsa_eppga',
theme: 'jobs',
params: { unit: 'PC', sex: 'T', age: ['Y15-64', 'Y15-24'] }
},
{
code: 'lfsa_ewhan2',
theme: 'jobs',
params: {
nace_r2: 'TOTAL',
wstatus: 'EMP',
worktime: ['TOTAL', 'FT'],
age: 'Y15-64',
sex: 'T',
unit: 'HR'
}
},
{
code: 'earn_mw_cur',
theme: 'jobs',
params: { currency: ['EUR', 'PPS'] },
geos: [...EU_GEOS, 'US']
},
{
// Price level indices (EU27 = 100): everything, food, alcohol & tobacco, housing, transport, restaurants.
code: 'prc_ppp_ind',
theme: 'jobs',
params: {
na_item: 'PLI_EU27_2020',
ppp_cat: ['GDP', 'A01', 'A0101', 'A0104', 'A0107', 'A0111']
},
geos: [...EU_GEOS, 'US', 'JP']
},
{
// Housing cost overburden: households spending > 40 % of disposable income on housing.
code: 'ilc_lvho07a',
theme: 'jobs',
params: { unit: 'PC', rskpovth: 'TOTAL', age: ['TOTAL', 'Y18-24', 'Y25-29'], sex: 'T' }
},
{
code: 'une_ltu_a',
theme: 'jobs',
params: { indic_em: 'LTU', age: 'Y15-74', sex: 'T', unit: 'PC_UNE' }
},
{
// Share of young adults still living with their parents.
code: 'ilc_lvps08',
theme: 'jobs',
params: { age: ['Y25-29', 'Y25-34'], sex: 'T', unit: 'PC' }
},
{
// Structure of earnings survey (every 4 years): mean and median earnings, firms with 10+ staff.
code: 'earn_ses_pub2s',
theme: 'jobs',
params: { sizeclas: 'GE10', sex: 'T', unit: ['EUR', 'PPS'] }
}
];
type JsonStat = {

View File

@@ -185,6 +185,103 @@ export const FRED_SERIES: Fred[] = [
freq: 'Q',
region: 'USA',
theme: 'economy'
},
// Jobs & cost of living
{
id: 'LNS14024887',
title: 'US unemployment rate, 16–24',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'LNS14000036',
title: 'US unemployment rate, 20–24',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'JTSJOL',
title: 'US job openings (JOLTS)',
unit: 'Thousands',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'JTSJOR',
title: 'US job openings rate (JOLTS)',
unit: '% of jobs + openings',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'JTSHIR',
title: 'US hires rate (JOLTS)',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'JTSQUR',
title: 'US quits rate (JOLTS)',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'UNEMPLOY',
title: 'US unemployed persons',
unit: 'Thousands',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'LNS13025703',
title: 'US long-term unemployed (27+ weeks), share of unemployed',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'CGBD2534',
title: 'US unemployment rate, bachelor’s degree, 25–34',
unit: '%',
freq: 'M',
region: 'USA',
theme: 'jobs'
},
{
id: 'LES1252881600Q',
title: 'US median usual weekly real earnings',
unit: '1982–84 CPI-adjusted USD',
freq: 'Q',
region: 'USA',
theme: 'jobs'
},
{
id: 'LES1252881500Q',
title: 'US median usual weekly earnings, full-time',
unit: 'USD',
freq: 'Q',
region: 'USA',
theme: 'jobs'
},
{
id: 'FEDMINNFRWG',
title: 'US federal minimum wage',
unit: 'USD per hour',
freq: 'M',
region: 'USA',
theme: 'jobs'
}
];

498
scripts/stats/jobs.ts Normal file
View File

@@ -0,0 +1,498 @@
// Jobs & cost of living (/stats/<place>/jobs), the parts that are not a plain Eurostat / World
// Bank / FRED series list:
// - OECD Data Explorer SDMX (no key): average and minimum wages in USD PPP, hours, long hours,
// temporary-job shares, long-term unemployment, graduate employment and the tertiary
// earnings premium (Education at a Glance), house price-to-income / price-to-rent, monthly
// youth unemployment and comparative price levels (USA = 100). Series `oecd:jobs:<name>:<ISO3>`.
// - Derived from those: real wage growth (yearly change of the constant-price average wage) and
// an approximate median full-time wage (real minimum wage ÷ minimum-to-median ratio),
// `jobs:realwagegrowth:<ISO3>` and `jobs:medianwage:<ISO3>`.
// - The Economist Big Mac index (GitHub CSV, CC BY 4.0): `economist:bigmac:<ISO3>:<measure>`.
// - Derived from FRED: US job openings per unemployed person (JTSJOL / UNEMPLOY).
// - Derived from World Bank: price level of private consumption, USA = 100
// (PA.NUS.PRVT.PP ÷ PA.NUS.FCRF; WDI dropped PA.NUS.PPPC.RF), `jobs:pricelevel:<ISO3>`.
// Numbeo is not used: it has no free API and its terms forbid scraping.
import {
getText,
http,
log,
parsePeriod,
REGIONS,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const OECD = 'https://sdmx.oecd.org/public/rest/data';
const CSV = { accept: 'application/vnd.sdmx.data+csv; charset=utf-8' };
/** Countries and aggregates asked from OECD; non-members simply come back empty. */
const AREAS = [
'NLD',
'USA',
'KOR',
'JPN',
'CAN',
'CHN',
'DEU',
'FRA',
'BEL',
'GBR',
'IND',
'BRA',
'EU27_2020',
'EA20',
'OECD'
];
const AREA_REGION: Record<string, string> = { EU27_2020: 'EU27', EA20: 'EA', OECD: 'OECD' };
const areaRegion = (a: string) => AREA_REGION[a] ?? a;
type OecdSpec = {
job: string;
/** Dataflow `agency,id,version`. */
flow: string;
/** Key after the area, e.g. `.WG.USD_PPP.A.Q.MEAN._Z` (the area list is prefixed). */
key: string;
start?: string;
/** Row → our series name (or null to skip). */
pick: (r: Record<string, string>) => string | null;
meta: Record<string, { title: string; unit: string }>;
};
const SPECS: OecdSpec[] = [
{
job: 'wages',
flow: 'OECD.ELS.SAE,DSD_EARNINGS@AV_AN_WAGE,1.0',
key: '.WG.USD_PPP.A.Q.MEAN._Z',
pick: () => 'avwage',
meta: {
avwage: {
title: 'Average annual wage, full-time equivalent (constant prices, USD PPP)',
unit: 'USD PPP'
}
}
},
{
job: 'wages',
flow: 'OECD.ELS.SAE,DSD_EARNINGS@RMW,1.0',
key: '.SM_WG.USD_PPP.A+H.Q._Z._Z',
pick: (r) => (r.PAY_PERIOD === 'A' ? 'minwage' : r.PAY_PERIOD === 'H' ? 'minwage_h' : null),
meta: {
minwage: { title: 'Real statutory minimum wage, yearly (USD PPP)', unit: 'USD PPP' },
minwage_h: { title: 'Real statutory minimum wage, hourly (USD PPP)', unit: 'USD PPP / h' }
}
},
{
job: 'wages',
flow: 'OECD.ELS.SAE,DSD_EARNINGS@MIN2AVE,1.0',
key: '.MR_WG_FT.PT_WG_A_SAL_FT._Z._Z.MEDIAN._Z',
pick: () => 'min2median',
meta: {
min2median: {
title: 'Minimum wage relative to the median full-time wage',
unit: '% of median'
}
}
},
{
job: 'quality',
flow: 'OECD.ELS.SAE,DSD_HW@DF_AVG_ANN_HRS_WKD,1.0',
key: '.HW.H_Y_PS._Z._Z.EMP.A.ACTUAL._T._Z.MEAN._Z._T',
pick: () => 'hours',
meta: { hours: { title: 'Average annual hours actually worked per worker', unit: 'hours' } }
},
{
job: 'quality',
flow: 'OECD.ELS.SAE,DSD_HW@DF_EMP_LNG_USL_WK_HRS,1.0',
key: '.SH_EMP_H.PT_WR_SEX_AGE_EMP_ST._T.Y15T64.EMP.W.USUAL._T._Z.OBS.H_GE50.MAIN',
pick: () => 'longhours',
meta: {
longhours: {
title: 'Employees and self-employed usually working 50+ hours a week',
unit: '% of employed'
}
}
},
{
job: 'quality',
flow: 'OECD.ELS.SAE,DSD_TEMP@DF_TEMP_I,1.0',
key: '.EMP_TEMP.PT_POP_SUB._T._T+Y15T24.ICSE93_1.A',
pick: (r) => (r.AGE === '_T' ? 'temp' : 'temp_youth'),
meta: {
temp: { title: 'Temporary employment, share of employees', unit: '% of employees' },
temp_youth: {
title: 'Temporary employment, share of employees aged 15–24',
unit: '% of employees'
}
}
},
{
job: 'finding',
flow: 'OECD.ELS.SAE,DSD_DUR@DF_DUR_I,1.0',
key: '.UNEMP.PT_POP_SUB._T._T.Y_GE1.A',
pick: () => 'ltu',
meta: {
ltu: { title: 'Long-term unemployment (1 year or more)', unit: '% of unemployed' }
}
},
{
job: 'finding',
flow: 'OECD.SDD.TPS,DSD_LFS@DF_IALFS_UNE_M,1.0',
key: '.UNE_LF_M.PT_LF_SUB._Z.Y._T.Y15T24+Y_GE25+Y_GE15._Z.M',
start: '1990-01',
pick: (r) =>
r.AGE === 'Y15T24'
? 'unemp_youth'
: r.AGE === 'Y_GE25'
? 'unemp_25plus'
: r.AGE === 'Y_GE15'
? 'unemp'
: null,
meta: {
unemp: { title: 'Unemployment rate, monthly (OECD harmonised)', unit: '%' },
unemp_youth: { title: 'Youth unemployment rate 15–24, monthly', unit: '%' },
unemp_25plus: { title: 'Unemployment rate 25+, monthly', unit: '%' }
}
},
{
job: 'grads',
flow: 'OECD.EDU.IMEP,DSD_EAG_LSO_EA@DF_LSO_NEAC_EMP,1.0',
key: '._T.Y25T34.ISCED11A_5T8._T.EMPLOYMENT._Z._T._Z.ED_NED.EMP._Z.PT_POP_SUB.OBS._Z.NEAC.A',
pick: () => 'grademp',
meta: {
grademp: {
title: 'Employment rate of tertiary-educated 25–34 year-olds',
unit: '% of age group'
}
}
},
{
job: 'grads',
flow: 'OECD.EDU.IMEP,DSD_EAG_LSO_EA@DF_LSO_EARN_REL_UPPER,1.0',
key: '._T.Y25T34.ISCED11A_5T8._T.EARN_RELATIVE._Z._T._Z.ED_NED.EMP._Z.PT_EARN_WR_ISCED11A_3.OBS.FT.EARNINGS.A',
pick: () => 'premium',
meta: {
premium: {
title:
'Earnings of tertiary-educated 25–34 year-olds (full-time), relative to upper secondary',
unit: 'upper secondary = 100'
}
}
},
{
job: 'housing',
flow: 'OECD.ECO.MPD,DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES,1.0',
key: '.Q.HPI_YDH_AVG+HPI_RPI_AVG+RPI.',
start: '1970-Q1',
pick: (r) =>
r.MEASURE === 'HPI_YDH_AVG'
? 'ptoi'
: r.MEASURE === 'HPI_RPI_AVG'
? 'ptor'
: r.MEASURE === 'RPI'
? 'rent'
: null,
meta: {
ptoi: {
title: 'House price-to-income ratio, vs its long-term average',
unit: 'long-term avg = 100'
},
ptor: {
title: 'House price-to-rent ratio, vs its long-term average',
unit: 'long-term avg = 100'
},
rent: { title: 'Rent price index', unit: 'Index 2015=100' }
}
},
{
job: 'prices',
flow: 'OECD.SDD.TPS,DSD_PPP_M@DF_PP_CPL_M,1.0',
key: '.M.CPL.IX..USA',
start: '2015-01',
pick: (r) => (r.COUNTERPART_AREA === 'USA' ? 'cpl' : null),
meta: {
cpl: {
title: 'Comparative price level of final consumption (USA = 100)',
unit: 'USA = 100'
}
}
}
];
function parseCsv(text: string): Record<string, string>[] {
const lines = text.trim().split(/\r?\n/);
const head = lines[0].split(',');
return lines.slice(1).map((l) => Object.fromEntries(l.split(',').map((v, i) => [head[i], v])));
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function pullOecd(spec: OecdSpec): Promise<number> {
const url = `${OECD}/${spec.flow}/${AREAS.join('+')}${spec.key}?startPeriod=${spec.start ?? '1990'}`;
const res = await http(url, { headers: CSV });
const rows = parseCsv(await res.text());
const series = new Map<string, SeriesRow>();
const obs: ObsRow[] = [];
for (const r of rows) {
const name = spec.pick(r);
if (!name || r.OBS_VALUE === '' || r.OBS_VALUE == null) continue;
const value = Number(r.OBS_VALUE);
const p = parsePeriod(r.TIME_PERIOD ?? '');
if (!p || !Number.isFinite(value)) continue;
const region = areaRegion(r.REF_AREA);
const id = `oecd:jobs:${name}:${region}`;
if (!series.has(id)) {
const m = spec.meta[name];
series.set(id, {
series_id: id,
source_id: 'oecd',
source_code: `${spec.flow}/${r.REF_AREA}${spec.key}`,
title: m.title,
unit: m.unit,
freq: p.freq,
region_code: region,
theme: 'jobs',
meta: { url: 'https://data-explorer.oecd.org/', flow: spec.flow }
});
}
obs.push({ series_id: id, period: p.date, value, status: r.OBS_STATUS || null });
}
if (!obs.length) throw new Error(`no rows for ${spec.flow}`);
// One row per (series, period): SDMX can repeat a period with a different status.
const seen = new Set<string>();
const unique = obs.filter((o) => {
const k = `${o.series_id}|${o.period}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
await writeSeries([...series.values()], unique);
log(`jobs oecd ${spec.flow.split(',')[1]}: ${unique.length} obs, ${series.size} series`);
return unique.length;
}
// ── Big Mac index ─────────────────────────────────────────────────────────────────────────
const BIGMAC =
'https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-full-index.csv';
const BIGMAC_REGION: Record<string, string> = { EUZ: 'EA' };
async function bigmac(): Promise<number> {
const rows = parseCsv(await getText(BIGMAC));
const known = new Set(REGIONS.map((r) => r.region_code));
const measures: Record<string, { title: string; unit: string }> = {
dollar_price: { title: 'Big Mac price in US dollars', unit: 'US$' },
USD_raw: {
title: 'Big Mac index: local currency vs the US dollar (raw)',
unit: '% over/under-valued'
}
};
const series = new Map<string, SeriesRow>();
const obs: ObsRow[] = [];
for (const r of rows) {
const region = BIGMAC_REGION[r.iso_a3] ?? r.iso_a3;
if (!known.has(region) || !/^\d{4}-\d{2}-\d{2}$/.test(r.date)) continue;
for (const [m, meta] of Object.entries(measures)) {
const raw = Number(r[m]);
if (r[m] === '' || !Number.isFinite(raw)) continue;
const id = `economist:bigmac:${region}:${m}`;
if (!series.has(id))
series.set(id, {
series_id: id,
source_id: 'economist',
source_code: `big-mac-full-index/${r.iso_a3}/${m}`,
title: meta.title,
unit: meta.unit,
freq: 'S',
region_code: region,
theme: 'jobs',
meta: { url: 'https://github.com/TheEconomist/big-mac-data' }
});
obs.push({
series_id: id,
period: r.date,
value: m === 'USD_raw' ? Math.round(raw * 1000) / 10 : raw,
status: null
});
}
}
if (!obs.length) throw new Error('Big Mac CSV: no rows');
await writeSeries([...series.values()], obs);
log(`jobs bigmac: ${obs.length} obs, ${series.size} series`);
return obs.length;
}
// ── Derived: US openings per unemployed person ─────────────────────────────────────────────
async function joltsRatio(): Promise<number> {
const rows = await sql<{ period: string; value: number }[]>`
select o.period::text as period, round((o.value / u.value)::numeric, 3)::float8 as value
from stats.obs o join stats.obs u on u.period = o.period and u.series_id = 'fred:UNEMPLOY'
where o.series_id = 'fred:JTSJOL' and u.value > 0 and o.value is not null
order by o.period`;
if (!rows.length) throw new Error('JOLTS ratio: run the fred collector first (JTSJOL, UNEMPLOY)');
const id = 'jobs:openings_per_unemployed:USA';
await writeSeries(
[
{
series_id: id,
source_id: 'fred',
source_code: 'JTSJOL/UNEMPLOY',
title: 'US job openings per unemployed person (JOLTS openings ÷ unemployed)',
unit: 'openings per unemployed',
freq: 'M',
region_code: 'USA',
theme: 'jobs',
meta: { url: 'https://fred.stlouisfed.org/series/JTSJOL', derived: 'JTSJOL / UNEMPLOY' }
}
],
rows.map((r) => ({ series_id: id, period: r.period, value: r.value, status: null }))
);
return rows.length;
}
// ── Derived: real wage growth and an approximate median wage ────────────────────────────────
async function wageDerived(): Promise<number> {
const growth = await sql<{ region: string; period: string; value: number }[]>`
select s.region_code as region, o.period::text as period,
round((100 * (o.value / p.value - 1))::numeric, 2)::float8 as value
from stats.series s
join stats.obs o on o.series_id = s.series_id
join stats.obs p on p.series_id = s.series_id and p.period = o.period - interval '1 year'
where s.series_id like 'oecd:jobs:avwage:%' and p.value > 0 and o.value is not null
order by 1, 2`;
const median = await sql<{ region: string; period: string; value: number }[]>`
select s.region_code as region, o.period::text as period,
round((o.value / (r.value / 100))::numeric, 0)::float8 as value
from stats.series s
join stats.obs o on o.series_id = s.series_id
join stats.obs r on r.series_id = 'oecd:jobs:min2median:' || s.region_code and r.period = o.period
where s.series_id like 'oecd:jobs:minwage:%' and r.value > 0 and o.value is not null
order by 1, 2`;
const write = async (
rows: { region: string; period: string; value: number }[],
name: string,
title: string,
unit: string,
derived: string
) => {
const regions = [...new Set(rows.map((r) => r.region))];
await writeSeries(
regions.map((region) => ({
series_id: `jobs:${name}:${region}`,
source_id: 'oecd',
source_code: derived,
title,
unit,
freq: 'A',
region_code: region,
theme: 'jobs',
meta: { url: 'https://data-explorer.oecd.org/', derived }
})),
rows.map((r) => ({
series_id: `jobs:${name}:${r.region}`,
period: r.period,
value: r.value,
status: null
}))
);
return rows.length;
};
if (!growth.length) throw new Error('wage derived: run the wages job first');
return (
(await write(
growth,
'realwagegrowth',
'Real average wage growth (constant prices)',
'%',
'yoy(AV_AN_WAGE, constant prices)'
)) +
(await write(
median,
'medianwage',
'Median full-time wage, approx. (real minimum wage ÷ minimum-to-median ratio)',
'USD PPP',
'RMW / MIN2AVE(median)'
))
);
}
// ── Derived: price level (USA = 100) from the World Bank PPP and exchange rate ─────────────
async function priceLevel(): Promise<number> {
const rows = await sql<{ region: string; period: string; value: number }[]>`
select p.region_code as region, o.period::text as period,
round((100 * o.value / x.value)::numeric, 1)::float8 as value
from stats.series p
join stats.obs o on o.series_id = p.series_id
join stats.obs x on x.period = o.period and x.series_id = 'wb:PA.NUS.FCRF:' || p.region_code
where p.series_id like 'wb:PA.NUS.PRVT.PP:%' and p.region_code is not null
and x.value > 0 and o.value is not null
order by 1, 2`;
if (!rows.length)
throw new Error('price level: run worldbank first (PA.NUS.PRVT.PP, PA.NUS.FCRF)');
const regions = [...new Set(rows.map((r) => r.region))];
await writeSeries(
regions.map((region) => ({
series_id: `jobs:pricelevel:${region}`,
source_id: 'worldbank',
source_code: 'PA.NUS.PRVT.PP/PA.NUS.FCRF',
title: 'Price level of household consumption (PPP ÷ exchange rate, USA = 100)',
unit: 'USA = 100',
freq: 'A',
region_code: region,
theme: 'jobs',
meta: {
url: 'https://data.worldbank.org/indicator/PA.NUS.PRVT.PP',
derived: '100 × PA.NUS.PRVT.PP / PA.NUS.FCRF'
}
})),
rows.map((r) => ({
series_id: `jobs:pricelevel:${r.region}`,
period: r.period,
value: r.value,
status: null
}))
);
return rows.length;
}
export async function jobs(opts: { only?: string; job: string }) {
await run('oecd', `jobs:${opts.job}`, async (note) => {
const want = (j: string) => !opts.only || opts.only.split(',').includes(j);
let total = 0;
for (const spec of SPECS) {
if (!want(spec.job)) continue;
try {
total += await pullOecd(spec);
} catch (error) {
note(`${spec.flow}: ${error instanceof Error ? error.message : String(error)}`);
}
await sleep(3200); // OECD: ≤ 20 requests a minute
}
if (want('bigmac'))
try {
total += await bigmac();
} catch (error) {
note(`bigmac: ${error instanceof Error ? error.message : String(error)}`);
}
if (want('jolts'))
try {
total += await joltsRatio();
} catch (error) {
note(`jolts: ${error instanceof Error ? error.message : String(error)}`);
}
if (want('wages') || want('derived'))
try {
total += await wageDerived();
} catch (error) {
note(`wage derived: ${error instanceof Error ? error.message : String(error)}`);
}
if (want('pricelevel'))
try {
total += await priceLevel();
} catch (error) {
note(`pricelevel: ${error instanceof Error ? error.message : String(error)}`);
}
if (!total) throw new Error('nothing loaded');
return total;
});
}

View File

@@ -102,6 +102,43 @@ export const WB_INDICATORS: { id: string; title: string; unit: string; theme: st
title: 'Renewable electricity output',
unit: '% of electricity',
theme: 'energy'
},
// Jobs & cost of living (modelled ILO estimates; cover China and most others).
{
id: 'SL.UEM.1524.ZS',
title: 'Youth unemployment, 15–24 (ILO modelled)',
unit: '%',
theme: 'jobs'
},
{
id: 'SL.UEM.NEET.ZS',
title: 'Youth not in education, employment or training (NEET)',
unit: '% of youth',
theme: 'jobs'
},
{
id: 'SL.UEM.ADVN.ZS',
title: 'Unemployment with advanced education',
unit: '% of labour force with advanced education',
theme: 'jobs'
},
{
id: 'SL.TLF.PART.ZS',
title: 'Part-time employment',
unit: '% of total employment',
theme: 'jobs'
},
{
id: 'PA.NUS.FCRF',
title: 'Official exchange rate',
unit: 'LCU per US$, period average',
theme: 'jobs'
},
{
id: 'PA.NUS.PRVT.PP',
title: 'PPP conversion factor, private consumption',
unit: 'LCU per international $',
theme: 'jobs'
}
];

View File

@@ -2,13 +2,30 @@
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type PriceSeries = { key: string; name: string; points: [number, number][] };
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
$props();
export type PriceSeries = {
key: string;
name: string;
points: [number, number][];
color?: string;
dashed?: boolean;
};
let {
series,
kind,
currency,
height = 'min(62vh, 560px)',
zoom = true
}: {
series: PriceSeries[];
kind: string;
currency: string;
height?: string;
zoom?: boolean;
} = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
@@ -22,15 +39,53 @@
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
const many = series.length > 100;
const legend = series.length > 1 && series.length <= 12;
const surface = style.getPropertyValue('--site-surface').trim();
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
let first = Infinity;
let last = -Infinity;
for (const line of series)
for (const [t] of line.points) {
if (t < first) first = t;
if (t > last) last = t;
}
const day = 86_400_000;
const short = Number.isFinite(first) && last - first < 7 * day;
const pad = short ? (7 * day - (last - first)) / 2 : 0;
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 78, right: 20, top: 28, bottom: 72 },
grid: {
left: 64,
right: 14,
top: legend ? 40 : 20,
bottom: zoom ? 64 : 28,
containLabel: false
},
legend: legend
? {
top: 0,
left: 0,
icon: 'roundRect',
itemWidth: 10,
itemHeight: 4,
textStyle: { color: muted, fontSize: 11 }
}
: { show: false },
xAxis: {
type: 'time',
min: short ? first - pad : undefined,
max: short ? last + pad : undefined,
minInterval: day,
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
axisLabel: {
color: muted,
hideOverlap: true,
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
},
splitLine: { show: false }
},
yAxis: {
@@ -39,44 +94,71 @@
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
splitLine: { lineStyle: { color: border } }
},
dataZoom: [
{ 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>

View File

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

View File

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

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

@@ -0,0 +1,39 @@
// Math tracker (/trackers/math): Evan Chen's "An Infinitely Large Napkin". Shared by the
// server (owner gate, loads) and the page.
/**
* The privacy switch. true: owner-only (404 for everyone else, lock icon in every tracker
* list). false: a public, read-only page for everyone else (notes stay owner-only, edits
* are always owner-only). Flip this one constant; owner.ts and projects.ts read it.
*/
export const MATH_PRIVATE = 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(', ');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

124
src/lib/napkin/map.test.ts Normal file
View 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
View 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
View 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
View 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));
}

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

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

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

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

View File

@@ -1,3 +1,4 @@
import { MATH_PRIVATE } from './math';
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
export const projects = [
{
@@ -33,70 +34,66 @@ export const projects = [
}
] as const;
// The public /stats section: open data on the economy, energy and Gouda (src/routes/stats).
// The public /stats section (src/routes/stats): a place × theme matrix; these are the shortcuts.
export const statsPages = [
{
name: 'economy',
icon: 'stats-economy',
subtitle: 'rates, inflation, bond spreads, GDP and debt',
href: '/stats/economy',
href: '/stats/core/economy',
external: false
},
{
name: 'jobs & cost of living',
icon: 'stats-jobs',
subtitle: 'unemployment, vacancies, graduate outcomes, wages, rents and price levels',
href: '/stats/core/jobs',
external: false
},
{
name: 'energy',
icon: 'stats-energy',
subtitle: 'live grid mix and gas storage, tariffs, oil and gas, renewables, heat pumps and EVs',
href: '/stats/energy',
href: '/stats/nld/energy',
external: false
},
{
name: 'gouda',
icon: 'stats-gouda',
subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda',
href: '/stats/gouda/overview',
external: false
},
{
name: 'countries',
icon: 'stats-countries',
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
href: '/stats/countries',
href: '/stats/core/overview',
external: false
},
{
name: 'elections',
icon: 'stats-elections',
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
href: '/stats/elections',
href: '/stats/nld/politics',
external: false
},
{
name: 'history',
icon: 'stats-history',
subtitle: 'the long run, a sourced timeline, politics and culture of five places',
href: '/stats/history',
href: '/stats/world/history',
external: false
},
{
name: 'opinions',
icon: 'stats-opinions',
subtitle: 'polls, Eurobarometer, happiness, party positions and world opinions',
href: '/stats/opinions',
href: '/stats/core/politics',
external: false
}
] as const;
/** Sub-tabs of a /stats section (second row under the stats nav), keyed by the section path. */
export const statsSubPages: Record<
string,
readonly { name: string; href: string; icon: string }[]
> = {
'/stats/opinions': [
{ name: 'polls & surveys', href: '/stats/opinions', icon: 'stats-polls' },
{ name: 'world', href: '/stats/opinions/world', icon: 'stats-world' }
]
};
type Tracker = {
name: string;
subtitle: string;
@@ -386,6 +383,27 @@ 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: 'radio',
subtitle: 'scanner frequencies, banks, nets, ISS passes and my listening log',
href: '/trackers/radio',
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',

View File

@@ -0,0 +1,40 @@
<script lang="ts">
// A small gradient tile per band (colourful icons on a black-and-white page).
import { BAND_COLOR, BAND_LABEL, type Band } from './index.js';
import Ship from 'lucide-svelte/icons/ship';
import Plane from 'lucide-svelte/icons/plane';
import RadioTower from 'lucide-svelte/icons/radio-tower';
import Radio from 'lucide-svelte/icons/radio';
import Antenna from 'lucide-svelte/icons/antenna';
let { band, size = 22 }: { band: string; size?: number } = $props();
const ICONS = { marine: Ship, air: Plane, ham: RadioTower, pmr: Radio, other: Antenna } as const;
const b = $derived((band in ICONS ? band : 'other') as Band);
const Icon = $derived(ICONS[b]);
const colors = $derived(BAND_COLOR[b]);
</script>
<span
class="band-icon"
style:--from={colors[0]}
style:--to={colors[1]}
style:--size="{size}px"
title={BAND_LABEL[b]}
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
>
<style>
.band-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>

285
src/lib/radio/Banks.svelte Normal file
View File

@@ -0,0 +1,285 @@
<script lang="ts">
// Uniden UBC125XLT memory: 10 banks x 50 channels. Pick a bank, tap a channel to edit it.
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Download from 'lucide-svelte/icons/download';
import X from 'lucide-svelte/icons/x';
import {
ALPHA_TAG_MAX,
BANDS,
BAND_LABEL,
BANKS,
PER_BANK,
bankOf,
bankRange,
formatFreq
} from './index.js';
import type { Channel, Frequency } from '#lib/server/radio.js';
let { channels, frequencies }: { channels: Channel[]; frequencies: Frequency[] } = $props();
const initialCh = Number(page.url.searchParams.get('ch')) || null;
let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4);
let selected = $state<number | null>(initialCh);
let error = $state<string | null>(null);
const byCh = $derived(new Map(channels.map((c) => [c.channel, c])));
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const perBank = $derived.by(() => {
const n = Array.from({ length: BANKS }, () => 0);
for (const c of channels) n[bankOf(c.channel) - 1]++;
return n;
});
const slots = $derived.by(() => {
const [a, b] = bankRange(bank);
return Array.from({ length: b - a + 1 }, (_, i) => a + i);
});
const sel = $derived(selected ? byCh.get(selected) : undefined);
const grouped = $derived(
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
(g) => g.list.length
)
);
</script>
<section class="card">
<div class="bar">
<div class="banks" role="tablist" aria-label="Scanner banks">
{#each perBank as n, i (i)}
<button
type="button"
role="tab"
aria-selected={bank === i + 1}
class={['bank', { active: bank === i + 1, empty: !n }]}
onclick={() => {
bank = i + 1;
selected = null;
}}><b>{i + 1}</b><span>{n}/{PER_BANK}</span></button
>
{/each}
</div>
<a class="ghost" href={resolve('/trackers/radio/banks.csv')} download data-sveltekit-reload
><Download size={14} />CSV</a
>
</div>
<p class="meta hint">
Bank {bank}: channels {bankRange(bank)[0]}–{bankRange(bank)[1]} · {channels.length} of 500 programmed
· Uniden UBC125XLT layout (model unconfirmed)
</p>
<div class="grid">
{#each slots as ch (ch)}
{@const c = byCh.get(ch)}
{@const f = c ? byId.get(c.frequencyId) : undefined}
<button
type="button"
class={['slot', { filled: !!f, sel: selected === ch, locked: c?.lockout }]}
onclick={() => (selected = selected === ch ? null : ch)}
aria-pressed={selected === ch}
>
<span class="no">{ch}</span>
{#if f && c}
<BandIcon band={f.band} size={16} />
<span class="tg">{c.tag}</span>
<span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span>
{:else}
<span class="tg dim">empty</span>
{/if}
</button>
{/each}
</div>
{#if selected}
{@const f = sel ? byId.get(sel.frequencyId) : undefined}
<form
class="edit chedit"
method="POST"
action="?/channel"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update({ reset: false });
}
}}
>
<input type="hidden" name="channel" value={selected} />
<div class="full bar">
<strong>Channel {selected}</strong>
<button type="button" class="icon" aria-label="close" onclick={() => (selected = null)}
><X size={15} /></button
>
</div>
{#key selected}
<label class="wide"
>frequency<select name="frequency_id" value={sel?.frequencyId ?? ''} required>
<option value="" disabled>pick a frequency…</option>
{#each grouped as g (g.band)}
<optgroup label={BAND_LABEL[g.band]}>
{#each g.list as o (o.id)}<option value={o.id}
>{formatFreq(o.freqHz)} · {o.name}</option
>{/each}
</optgroup>
{/each}
</select></label
>
<label
>alpha tag<input
name="alpha_tag"
maxlength={ALPHA_TAG_MAX}
value={sel?.alphaTag ?? ''}
placeholder={sel?.tag ?? 'auto from name'}
/></label
>
<div class="checks">
<label><input type="checkbox" name="delay" checked={sel?.delay ?? true} />delay</label>
<label
><input type="checkbox" name="lockout" checked={sel?.lockout ?? false} />lockout</label
>
<label
><input
type="checkbox"
name="priority"
checked={sel?.priority ?? false}
/>priority</label
>
</div>
{/key}
{#if f?.notes}<p class="meta full">{f.notes}</p>{/if}
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if sel}<button class="ghost" name="clear" value="1" formnovalidate>clear channel</button
>{/if}
<button class="primary">save</button>
</div>
</form>
{/if}
</section>
<style>
.banks {
display: flex;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
}
.bank {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 2.8rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
color: var(--site-text);
line-height: 1.1;
}
.bank b {
font-size: 0.95rem;
}
.bank span {
font-size: 0.62rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.bank.empty {
opacity: 0.55;
}
.bank.active {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
opacity: 1;
}
.bank.active span {
color: inherit;
opacity: 0.7;
}
.hint {
margin: 0.5rem 0 0.7rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.3rem;
}
.slot {
display: grid;
grid-template-columns: 1.9rem 16px 1fr;
grid-template-rows: auto auto;
column-gap: 0.4rem;
align-items: center;
padding: 0.35rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
text-align: left;
background: var(--site-bg);
min-width: 0;
font-family: ui-monospace, monospace;
}
.slot:hover {
border-color: var(--site-muted);
}
.slot.sel {
border-color: var(--site-text);
box-shadow: 0 0 0 1px var(--site-text);
}
.slot.locked {
opacity: 0.5;
}
.no {
grid-row: 1 / 3;
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.slot :global(.band-icon) {
grid-row: 1 / 3;
}
.tg {
font-size: 0.74rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.tg.dim {
grid-column: 2 / 4;
color: var(--site-muted);
opacity: 0.6;
text-transform: none;
}
.fq {
grid-column: 3;
font-size: 0.68rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.fq i {
font-style: normal;
opacity: 0.7;
}
.chedit {
margin-top: 0.8rem;
}
.checks {
display: flex;
gap: 0.8rem;
align-items: flex-end;
font-size: 0.8rem;
padding-bottom: 0.4rem;
}
.checks label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
// Add or edit one frequency.
import { enhance } from '$app/forms';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { BANDS, BAND_LABEL, MODES, formatFreq } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let { freq = null, ondone }: { freq?: Frequency | null; ondone?: () => void } = $props();
let error = $state<string | null>(null);
</script>
<form
class="edit"
method="POST"
action="?/saveFreq"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update({ reset: !freq });
ondone?.();
}
}}
>
{#if freq}<input type="hidden" name="id" value={freq.id} />{/if}
<label class="w-freq"
>frequency<input
name="freq"
required
inputmode="decimal"
value={freq ? formatFreq(freq.freqHz) : ''}
placeholder="145.450"
/></label
>
<label
>mode<select name="mode" value={freq?.mode ?? 'FM'}>
{#each MODES as m (m)}<option value={m}>{m}</option>{/each}
</select></label
>
<label
>band<select name="band" value={freq?.band ?? 'other'}>
{#each BANDS as b (b)}<option value={b}>{BAND_LABEL[b]}</option>{/each}
</select></label
>
<label class="w-ch">marine ch<input name="marine_ch" value={freq?.marineCh ?? ''} /></label>
<label class="wide">name<input name="name" required value={freq?.name ?? ''} /></label>
<label class="w-loc">location<input name="location" value={freq?.location ?? ''} /></label>
<label class="wide"
>tags<input
name="tags"
value={freq?.tags.join(', ') ?? ''}
placeholder="tower, emergency"
/></label
>
<label class="full">notes<textarea name="notes" rows="2">{freq?.notes ?? ''}</textarea></label>
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if freq}
<button
type="submit"
class="icon danger"
formaction="?/deleteFreq"
aria-label="delete frequency"
title="delete (also clears its scanner channels)"
onclick={(e) => {
if (!confirm(`Delete ${freq.name}? Its scanner channels are cleared too.`))
e.preventDefault();
}}><Trash2 size={15} /></button
>
{/if}
{#if ondone}<button type="button" class="ghost" onclick={ondone}>cancel</button>{/if}
<button class="primary">{freq ? 'save' : 'add frequency'}</button>
</div>
</form>

View File

@@ -0,0 +1,241 @@
<script lang="ts">
// Frequencies: search, band filter, inline edit.
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import FreqForm from './FreqForm.svelte';
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import { BANDS, BAND_LABEL, formatFreq, mhz } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let { frequencies }: { frequencies: Frequency[] } = $props();
let q = $state(page.url.searchParams.get('q') ?? '');
let band = $state<string | null>(page.url.searchParams.get('band'));
let editing = $state<number | null>(null);
let adding = $state(false);
const counts = $derived.by(() => {
const c: Record<string, number> = {};
for (const f of frequencies) c[f.band] = (c[f.band] ?? 0) + 1;
return c;
});
const shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
const num = needle.replace(',', '.').replace(/\s*mhz$/, '');
return frequencies.filter((f) => {
if (band && f.band !== band) return false;
if (!needle) return true;
if (/^\d+(\.\d*)?$/.test(num))
return mhz(f.freqHz).startsWith(num) || String(f.freqHz / 1000).startsWith(num);
if (/^ch\s*\d+$/.test(needle)) {
const n = Number(needle.replace(/\D/g, ''));
return f.channels.includes(n) || f.marineCh === String(n).padStart(2, '0');
}
return [f.name, f.location, f.notes, f.marineCh && `ch ${f.marineCh}`, ...f.tags]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(needle);
});
});
</script>
<section class="card">
<div class="bar">
<label class="search">
<Search size={15} />
<input
type="search"
placeholder="search name, 145.45, ch 16, tag"
aria-label="Search frequencies"
bind:value={q}
/>
</label>
<button type="button" class="primary" onclick={() => (adding = !adding)}
><Plus size={15} />add</button
>
</div>
<div class="chips" role="group" aria-label="Filter by band">
<button type="button" class={['chip', { active: !band }]} onclick={() => (band = null)}
>all <span class="n">{frequencies.length}</span></button
>
{#each BANDS.filter((b) => counts[b]) as b (b)}
<button
type="button"
class={['chip', { active: band === b }]}
onclick={() => (band = band === b ? null : b)}
><BandIcon band={b} size={18} />{BAND_LABEL[b]} <span class="n">{counts[b]}</span></button
>
{/each}
</div>
{#if adding}<FreqForm ondone={() => (adding = false)} />{/if}
<ul class="freqs">
{#each shown as f (f.id)}
<li>
{#if editing === f.id}
<FreqForm freq={f} ondone={() => (editing = null)} />
{:else}
<div class="frow">
<BandIcon band={f.band} size={26} />
<span class="fq"
>{formatFreq(f.freqHz, false)}<small>{f.freqHz < 30e6 ? 'kHz' : 'MHz'}</small></span
>
<span class="md">{f.mode}</span>
<div class="nm">
<span class="name">{f.name}</span>
<span class="sub">
{#if f.marineCh}<span class="tag">ch {f.marineCh}</span>{/if}
{#if f.location}<span>{f.location}</span>{/if}
{#each f.tags as t (t)}<span class="tag">{t}</span>{/each}
{#if f.notes}<span class="note">{f.notes}</span>{/if}
</span>
</div>
<div class="chs">
{#each f.channels as c (c)}<a
class="chan"
href={resolve(`/trackers/radio?tab=banks&ch=${c}` as '/')}
title="scanner channel {c}">{c}</a
>{/each}
{#if f.logCount}<a
class="logged"
href={resolve(`/trackers/radio?tab=log&f=${f.id}` as '/')}
title="log entries">{f.logCount}×</a
>{/if}
</div>
<button
type="button"
class="icon"
aria-label="edit {f.name}"
onclick={() => (editing = f.id)}><Pencil size={14} /></button
>
</div>
{/if}
</li>
{:else}
<li class="empty">No frequencies match.</li>
{/each}
</ul>
</section>
<style>
.search {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 14rem;
max-width: 24rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
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);
}
.chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin: 0.7rem 0;
}
.freqs {
display: flex;
flex-direction: column;
}
.freqs > li + li {
border-top: 1px solid var(--site-border);
}
.frow {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0;
}
.fq {
width: 6.6rem;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace;
font-size: 0.86rem;
}
.fq small {
margin-left: 0.2rem;
font-size: 0.65rem;
color: var(--site-muted);
}
.md {
width: 2.6rem;
flex-shrink: 0;
font-size: 0.7rem;
color: var(--site-muted);
}
.nm {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.name {
font-size: 0.88rem;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
font-size: 0.74rem;
color: var(--site-muted);
}
.note {
flex-basis: 100%;
}
.chs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.chan,
.logged {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
padding: 0.05rem 0.4rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.logged {
background: color-mix(in srgb, #34d399 18%, transparent);
}
.chan:hover,
.logged:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.frow {
flex-wrap: wrap;
}
.md {
width: auto;
}
.nm {
flex-basis: calc(100% - 2.5rem);
order: 5;
padding-left: 2.1rem;
}
.chs {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,213 @@
<script lang="ts">
// ISS passes over Gouda: start / max / end (Amsterdam time), max elevation, direction.
import Eye from 'lucide-svelte/icons/eye';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
QUIET_HOURS,
REMIND_MINUTES,
compass,
dayKey,
fmtTime,
formatFreq,
parseQuiet,
relDay,
until
} from './index.js';
import type { IssPass } from '#lib/server/radio.js';
let {
passes,
tle = null,
now,
limit = 0,
compact = false
}: {
passes: IssPass[];
tle?: { name: string; epoch: string | null; fetchedAt: string | null } | null;
now: number;
limit?: number;
compact?: boolean;
} = $props();
let showLow = $state(false);
const list = $derived.by(() => {
const l = passes.filter(
(p) => new Date(p.endAt).getTime() >= now && (showLow || p.maxEl >= ISS_MIN_EL)
);
return limit ? l.slice(0, limit) : l;
});
const days = $derived.by(() => {
const out: { key: string; label: string; passes: IssPass[] }[] = [];
for (const p of list) {
const k = dayKey(p.startAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(p.startAt, now), passes: [] });
out.at(-1)!.passes.push(p);
}
return out;
});
const mins = (p: IssPass) =>
Math.round((new Date(p.endAt).getTime() - new Date(p.startAt).getTime()) / 60_000);
const fmtAgo = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
timeZone: 'Europe/Amsterdam',
dateStyle: 'medium',
timeStyle: 'short'
});
</script>
{#if !compact}
<div class="bar">
<p class="meta">
Gouda (52.011 N, 4.711 E) · listen on <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
(ARISS voice / SSTV) ·
<Eye size={12} /> = sunlit in a dark sky, visible to the eye
</p>
<label class="low"
><input type="checkbox" bind:checked={showLow} />passes below {ISS_MIN_EL}°</label
>
</div>
{/if}
<div class="iss">
{#if days.length}
{#each days as d (d.key)}
<h3 class="day">{d.label}</h3>
<ul class="passes">
{#each d.passes as p (p.startAt)}
{@const live = new Date(p.startAt).getTime() <= now}
<li class={{ low: p.maxEl < ISS_MIN_EL, live }}>
<span class="times">
<b>{fmtTime(p.startAt)}</b><span class="arrow">→</span>{fmtTime(p.maxAt)}<span
class="arrow">→</span
>{fmtTime(p.endAt)}
</span>
<span class="el" title="max elevation">
<span class="elbar"><i style:width="{Math.min(100, (p.maxEl / 90) * 100)}%"></i></span
>{Math.round(p.maxEl)}°
</span>
<span class="dir">{compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}</span>
<span class="dur">{mins(p)} min</span>
<span class="vis"
>{#if p.sunlit}<Eye size={14} aria-label="visible to the eye" />{/if}</span
>
<span class="rel">{live ? 'overhead now' : until(p.startAt, now)}</span>
</li>
{/each}
</ul>
{/each}
{:else}
<p class="empty">No passes computed yet (the daily refresh fills this).</p>
{/if}
</div>
{#if !compact && tle}
<p class="meta foot">
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt
? fmtAgo(tle.fetchedAt)
: '?'} (refreshed daily). Passes of {ISS_MIN_EL}° and up get a reminder {REMIND_MINUTES} min before,
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
</p>
{/if}
<style>
.low {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.day {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.passes {
display: flex;
flex-direction: column;
}
li {
display: grid;
grid-template-columns: 11.5rem 5.5rem 1fr 3.5rem 1.2rem 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.3rem;
border-radius: 0.45rem;
font-size: 0.84rem;
font-variant-numeric: tabular-nums;
}
li + li {
border-top: 1px solid var(--site-border);
}
li.low {
opacity: 0.5;
}
li.live {
background: color-mix(in srgb, #2dd4bf 14%, transparent);
}
.arrow {
margin: 0 0.25rem;
color: var(--site-muted);
}
.el {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.elbar {
width: 2.4rem;
height: 5px;
border-radius: 3px;
background: var(--site-border);
overflow: hidden;
}
.elbar i {
display: block;
height: 100%;
background: linear-gradient(90deg, #2dd4bf, #0f766e);
}
.dir,
.dur,
.rel {
color: var(--site-muted);
font-size: 0.78rem;
}
.rel {
text-align: right;
}
.vis {
color: #fbbf24;
display: inline-flex;
}
.foot {
margin-top: 0.8rem;
}
.iss {
container-type: inline-size;
}
@container (max-width: 40rem) {
li {
grid-template-columns: minmax(0, 1fr) auto 1.2rem;
row-gap: 0.1rem;
}
.times {
grid-area: 1 / 1;
}
.el {
grid-area: 1 / 2;
}
.vis {
grid-area: 1 / 3;
}
.dir {
grid-area: 2 / 1;
}
.dur {
display: none;
}
.rel {
grid-area: 2 / 2 / 3 / 4;
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
// Listening log, newest first, grouped by day.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Signal from './Signal.svelte';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { dayKey, fmtTime, formatFreq, mhz, relDay } from './index.js';
import type { Frequency, LogEntry } from '#lib/server/radio.js';
let {
log,
frequencies,
now,
limit = 0,
frequencyId = null
}: {
log: LogEntry[];
frequencies: Frequency[];
now: number;
limit?: number;
frequencyId?: number | null;
} = $props();
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const days = $derived.by(() => {
let l = frequencyId ? log.filter((e) => e.frequencyId === frequencyId) : log;
if (limit) l = l.slice(0, limit);
const out: { key: string; label: string; entries: LogEntry[] }[] = [];
for (const e of l) {
const k = dayKey(e.heardAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(e.heardAt, now), entries: [] });
out.at(-1)!.entries.push(e);
}
return out;
});
</script>
{#each days as d (d.key)}
<h3 class="day">{d.label}</h3>
<ul class="log">
{#each d.entries as e (e.id)}
{@const f = e.frequencyId ? byId.get(e.frequencyId) : undefined}
<li>
<span class="time">{fmtTime(e.heardAt)}</span>
<BandIcon band={f?.band ?? 'other'} size={22} />
<div class="body">
<span class="what">{e.what}</span>
<span class="sub">
{#if e.freqHz}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(e.freqHz)}` as '/')}
class="fq">{formatFreq(e.freqHz)}{e.mode ? ` ${e.mode}` : ''}</a
>{/if}
{#if f}<span>{f.name}</span>{/if}
{#if e.notes}<span class="note">{e.notes}</span>{/if}
</span>
</div>
<Signal value={e.signal} />
<form method="POST" action="?/deleteLog" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button
class="icon danger"
aria-label="delete log entry"
onclick={(ev) => {
if (!confirm('Delete this log entry?')) ev.preventDefault();
}}><Trash2 size={14} /></button
>
</form>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing logged yet. Heard something? Log it above.</p>
{/each}
<style>
.day {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.log {
display: flex;
flex-direction: column;
}
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
}
li + li {
border-top: 1px solid var(--site-border);
}
.time {
width: 2.8rem;
flex-shrink: 0;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.what {
font-size: 0.88rem;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.6rem;
font-size: 0.74rem;
color: var(--site-muted);
}
.fq {
font-variant-numeric: tabular-nums;
}
.fq:hover {
text-decoration: underline;
}
</style>

286
src/lib/radio/Nets.svelte Normal file
View File

@@ -0,0 +1,286 @@
<script lang="ts">
// Nets and broadcast schedules (Amsterdam time), with the ntfy reminder toggle per net.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Bell from 'lucide-svelte/icons/bell';
import BellOff from 'lucide-svelte/icons/bell-off';
import Pencil from 'lucide-svelte/icons/pencil';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
import {
BANDS,
BAND_LABEL,
DAY_SHORT,
QUIET_HOURS,
REMIND_MINUTES,
parseQuiet,
daysLabel,
fmtTime,
formatFreq,
mhz,
nextOccurrence,
relDay,
until
} from './index.js';
import type { Frequency, Net } from '#lib/server/radio.js';
let { nets, frequencies, now }: { nets: Net[]; frequencies: Frequency[]; now: number } = $props();
let editing = $state<number | 'new' | null>(null);
let error = $state<string | null>(null);
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const grouped = $derived(
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
(g) => g.list.length
)
);
const KIND = { net: 'net', broadcast: 'broadcast', other: 'other' } as const;
</script>
{#snippet form(n: Net | null)}
<form
class="edit"
method="POST"
action="?/saveNet"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update();
editing = null;
}
}}
>
{#if n}<input type="hidden" name="id" value={n.id} />{/if}
<label class="wide">name<input name="name" required value={n?.name ?? ''} /></label>
<label
>kind<select name="kind" value={n?.kind ?? 'net'}>
{#each Object.keys(KIND) as k (k)}<option value={k}>{k}</option>{/each}
</select></label
>
<label class="wide"
>frequency<select name="frequency_id" value={n?.frequencyId ?? ''}>
<option value="">none</option>
{#each grouped as g (g.band)}
<optgroup label={BAND_LABEL[g.band]}>
{#each g.list as o (o.id)}<option value={o.id}>{formatFreq(o.freqHz)} · {o.name}</option
>{/each}
</optgroup>
{/each}
</select></label
>
<fieldset class="full days">
<legend>days</legend>
{#each DAY_SHORT as d, i (d)}
<label
><input
type="checkbox"
name="days"
value={i + 1}
checked={n?.days.includes(i + 1) ?? i < 5}
/>{d}</label
>
{/each}
</fieldset>
<label
>times<input
name="times"
value={n?.times.join(', ') ?? ''}
placeholder="11:00, 19:05"
/></label
>
<label
>minutes<input name="duration_min" inputmode="numeric" value={n?.durationMin ?? ''} /></label
>
<label class="inline"
><input type="checkbox" name="remind" checked={n?.remind ?? true} />remind {REMIND_MINUTES} min
before</label
>
<label class="full">notes<textarea name="notes" rows="2">{n?.notes ?? ''}</textarea></label>
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if n}
<button
class="icon danger"
formaction="?/deleteNet"
aria-label="delete net"
onclick={(e) => {
if (!confirm(`Delete ${n.name}?`)) e.preventDefault();
}}><Trash2 size={15} /></button
>
{/if}
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
<button class="primary">{n ? 'save' : 'add'}</button>
</div>
</form>
{/snippet}
<section class="card">
<div class="bar">
<p class="meta">
Times are Amsterdam time. A bell sends an ntfy reminder {REMIND_MINUTES} minutes before each start,
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
</p>
<button
type="button"
class="primary"
onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button
>
</div>
{#if editing === 'new'}{@render form(null)}{/if}
<ul class="nets">
{#each nets as n (n.id)}
{@const f = n.frequencyId ? byId.get(n.frequencyId) : undefined}
{@const next = nextOccurrence(n, new Date(now))}
<li>
{#if editing === n.id}
{@render form(n)}
{:else}
<div class="net">
<BandIcon band={f?.band ?? 'other'} size={30} />
<div class="body">
<div class="top">
<strong>{n.name}</strong>
<span class="tag">{n.kind}</span>
</div>
<div class="sub">
{#if f}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(f.freqHz)}` as '/')}
class="fq"
>{formatFreq(f.freqHz)}
{f.mode}{f.marineCh ? ` · ch ${f.marineCh}` : ''}{f.channels.length
? ` · scanner ${f.channels.join(', ')}`
: ''}</a
>{/if}
<span
>{daysLabel(n.days)}
{n.times.length ? n.times.join(' & ') : '· time unknown'}</span
>
</div>
{#if n.notes}<p class="meta">{n.notes}</p>{/if}
</div>
<div class="right">
{#if next}<span class="next">{relDay(next, now)} {fmtTime(next)}</span><span
class="meta">{until(next, now)}</span
>{/if}
</div>
<form method="POST" action="?/remind" use:enhance>
<input type="hidden" name="id" value={n.id} />
<button
class={['icon', 'bell', { on: n.remind && n.times.length }]}
aria-label={n.remind ? 'reminder on, turn off' : 'reminder off, turn on'}
title={n.times.length
? n.remind
? 'reminder on'
: 'reminder off'
: 'no time set, so no reminder'}
>{#if n.remind}<Bell size={15} />{:else}<BellOff size={15} />{/if}</button
>
</form>
<button
type="button"
class="icon"
aria-label="edit {n.name}"
onclick={() => (editing = n.id)}><Pencil size={14} /></button
>
</div>
{/if}
</li>
{:else}
<li class="empty">No nets yet.</li>
{/each}
</ul>
</section>
<style>
.nets {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
}
.nets > li + li {
border-top: 1px solid var(--site-border);
}
.net {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.top {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.fq {
font-variant-numeric: tabular-nums;
}
.fq:hover {
text-decoration: underline;
}
.right {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
}
.next {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.bell.on {
color: #fbbf24;
}
.days {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
border: 0;
padding: 0;
font-size: 0.8rem;
}
.days legend {
font-size: 0.72rem;
color: var(--site-muted);
margin-bottom: 0.2rem;
}
.days label,
.inline {
display: inline-flex !important;
flex-direction: row !important;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem !important;
color: var(--site-text) !important;
}
@media (max-width: 640px) {
.net {
flex-wrap: wrap;
}
.right {
order: 5;
flex-direction: row;
gap: 0.5rem;
align-items: baseline;
padding-left: 2.6rem;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script lang="ts">
// Quick listening-log entry, built for a phone: frequency (or "ch 353"), what you heard,
// signal 1-5. Time defaults to now; "earlier" opens a time field.
import { enhance } from '$app/forms';
import Plus from 'lucide-svelte/icons/plus';
import { mhz } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let {
frequencies,
message = null,
error = false
}: { frequencies: Frequency[]; message?: string | null; error?: boolean } = $props();
let signal = $state<number | null>(null);
let earlier = $state(false);
let busy = $state(false);
// Datalist: one option per frequency value (the scanner ones first, then the rest).
const options = $derived.by(() => {
const seen: Record<number, true> = {};
const sorted = [...frequencies].sort(
(a, b) => Number(b.channels.length > 0) - Number(a.channels.length > 0) || a.freqHz - b.freqHz
);
const out: { value: string; label: string }[] = [];
for (const f of sorted) {
if (seen[f.freqHz]) continue;
seen[f.freqHz] = true;
out.push({ value: mhz(f.freqHz), label: f.name });
}
return out.sort((a, b) => Number(a.value) - Number(b.value));
});
</script>
<form
class="quick"
method="POST"
action="?/log"
use:enhance={() => {
busy = true;
return async ({ update, result }) => {
await update();
busy = false;
if (result.type === 'success') {
signal = null;
earlier = false;
}
};
}}
>
<div class="row">
<input
name="freq"
class="freq"
placeholder="145.450 or ch 353"
list="radio-freqs"
inputmode="decimal"
autocomplete="off"
aria-label="Frequency or scanner channel"
required
/>
<input
name="what"
class="what"
placeholder="what did you hear?"
aria-label="What you heard"
required
/>
</div>
<datalist id="radio-freqs">
{#each options as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</datalist>
<div class="row">
<div class="signal" role="radiogroup" aria-label="Signal quality">
{#each [1, 2, 3, 4, 5] as s (s)}
<label class={{ on: signal !== null && s <= signal }}
><input type="radio" name="signal" value={s} bind:group={signal} /><span>{s}</span></label
>
{/each}
</div>
<button type="button" class="ghost small" onclick={() => (earlier = !earlier)}
>{earlier ? 'now' : 'earlier?'}</button
>
<button class="primary" disabled={busy}><Plus size={15} />log</button>
</div>
{#if earlier}
<div class="row">
<input type="datetime-local" name="heard_at" aria-label="When (Amsterdam time)" />
<input name="notes" placeholder="notes" aria-label="Notes" class="what" />
</div>
{/if}
{#if message}<p class={['msg', { error }]} role="status">{message}</p>{/if}
</form>
<style>
.quick {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
input:not([type='radio']) {
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
background: var(--site-bg);
color: var(--site-text);
font: inherit;
font-size: 16px; /* no zoom on iOS */
min-width: 0;
}
input:focus {
outline: none;
border-color: var(--site-muted);
}
.freq {
flex: 0 1 11rem;
font-variant-numeric: tabular-nums;
}
.what {
flex: 1 1 12rem;
}
.signal {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
overflow: hidden;
}
.signal label {
position: relative;
cursor: pointer;
}
.signal input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.signal span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
height: 2.3rem;
font-size: 0.85rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.signal label + label span {
border-left: 1px solid var(--site-border);
}
.signal label.on span {
background: color-mix(in srgb, #34d399 22%, transparent);
color: var(--site-text);
}
.signal label:focus-within span {
outline: 2px solid var(--site-muted);
outline-offset: -2px;
}
.primary {
margin-left: auto;
height: 2.4rem;
padding: 0 1rem !important;
}
.small {
height: 2.4rem;
}
.msg {
font-size: 0.8rem;
color: #34d399;
}
.msg.error {
color: #f87171;
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
// Signal quality 1..5 as five little bars.
let { value }: { value: number | null } = $props();
const LABEL = ['', 'barely', 'weak', 'fair', 'good', 'loud and clear'];
</script>
{#if value}
<span class="sig" title="signal {value}/5: {LABEL[value]}" aria-label="signal {value} of 5">
{#each [1, 2, 3, 4, 5] as i (i)}<i class={{ on: i <= value }} style:height="{3 + i * 2}px"
></i>{/each}
</span>
{/if}
<style>
.sig {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 13px;
}
i {
width: 3px;
border-radius: 1px;
background: var(--site-border);
}
i.on {
background: #34d399;
}
</style>

34
src/lib/radio/Tile.svelte Normal file
View File

@@ -0,0 +1,34 @@
<script lang="ts">
// Gradient icon tile for section headings and tabs.
import type { Component } from 'svelte';
let {
icon: Icon,
from,
to,
size = 22
}: {
icon: Component<{ size?: number; strokeWidth?: number }>;
from: string;
to: string;
size?: number;
} = $props();
</script>
<span class="tile" style:--from={from} style:--to={to} style:--size="{size}px" aria-hidden="true"
><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
>
<style>
.tile {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--size);
height: var(--size);
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
}
</style>

186
src/lib/radio/UpNext.svelte Normal file
View File

@@ -0,0 +1,186 @@
<script lang="ts">
// "Up next": the next net/broadcast starts and good ISS passes, soonest first.
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Satellite from 'lucide-svelte/icons/satellite';
import Bell from 'lucide-svelte/icons/bell';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
compass,
fmtTime,
formatFreq,
occurrences,
relDay,
until
} from './index.js';
import type { Frequency, IssPass, Net } from '#lib/server/radio.js';
let {
nets,
passes,
frequencies,
now,
limit = 6
}: {
nets: Net[];
passes: IssPass[];
frequencies: Frequency[];
now: number;
limit?: number;
} = $props();
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
type Item = {
key: string;
at: number;
end: number | null;
title: string;
sub: string;
band: string | null;
iss: boolean;
remind: boolean;
};
const items = $derived.by(() => {
const from = new Date(now - 60 * 60_000);
const to = new Date(now + 3 * 86_400_000);
const out: Item[] = [];
for (const n of nets) {
const f = n.frequencyId ? byId.get(n.frequencyId) : undefined;
for (const at of occurrences(n, from, to)) {
const end = n.durationMin
? at.getTime() + n.durationMin * 60_000
: at.getTime() + 15 * 60_000;
if (end < now) continue;
out.push({
key: `n${n.id}-${at.getTime()}`,
at: at.getTime(),
end,
title: n.name,
sub: f
? `${formatFreq(f.freqHz)} ${f.mode}${f.marineCh ? ` · ch ${f.marineCh}` : ''}`
: '',
band: f?.band ?? 'other',
iss: false,
remind: n.remind
});
}
}
for (const p of passes) {
if (p.maxEl < ISS_MIN_EL) continue;
const end = new Date(p.endAt).getTime();
if (end < now) continue;
out.push({
key: `iss-${p.startAt}`,
at: new Date(p.startAt).getTime(),
end,
title: `ISS pass, max ${Math.round(p.maxEl)}°`,
sub: `${compass(p.startAz)} → ${compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ' · visible' : ''}`,
band: null,
iss: true,
remind: true
});
}
return out.sort((a, b) => a.at - b.at).slice(0, limit);
});
</script>
{#if items.length}
<ol class="upnext">
{#each items as it (it.key)}
{@const live = it.at <= now && (it.end ?? it.at) >= now}
<li class={{ live }}>
{#if it.iss}<span class="iss-icon" aria-hidden="true"
><Satellite size={13} strokeWidth={2.25} /></span
>{:else}<BandIcon band={it.band ?? 'other'} size={26} />{/if}
<div class="what">
<a href={resolve(`/trackers/radio?tab=${it.iss ? 'iss' : 'nets'}` as '/')} class="t"
>{it.title}</a
>
<span class="s">{it.sub}</span>
</div>
<div class="when">
<span class="clock">{relDay(it.at, now)} {fmtTime(it.at)}</span>
<span class="rel"
>{live ? 'on now' : until(it.at, now)}{#if it.remind}<Bell
size={11}
aria-label="reminder on"
/>{/if}</span
>
</div>
</li>
{/each}
</ol>
{:else}
<p class="empty">Nothing scheduled in the next 3 days.</p>
{/if}
<style>
.upnext {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.35rem;
border-radius: 0.5rem;
}
li.live {
background: color-mix(in srgb, #34d399 12%, transparent);
}
.iss-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #2dd4bf, #0f766e);
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.t {
font-size: 0.88rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t:hover {
text-decoration: underline;
}
.s,
.rel {
font-size: 0.74rem;
color: var(--site-muted);
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
}
.clock {
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.rel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
li.live .rel {
color: #34d399;
font-weight: 600;
}
</style>

107
src/lib/radio/index.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import {
alphaTag,
bankOf,
fromZoned,
inQuiet,
parseQuiet,
nextOccurrence,
occurrences,
parseFreq,
scannerCsv
} from './index';
describe('parseFreq', () => {
it('reads MHz, kHz and Hz', () => {
expect(parseFreq('145.450')).toBe(145_450_000);
expect(parseFreq('145,45 MHz')).toBe(145_450_000);
expect(parseFreq('3785 kHz')).toBe(3_785_000);
expect(parseFreq('3785')).toBe(3_785_000);
expect(parseFreq('145450000')).toBe(145_450_000);
expect(parseFreq('ch 16')).toBeNull();
expect(parseFreq('')).toBeNull();
});
});
describe('net schedule', () => {
const ronde = { id: 1, days: [1, 2, 3, 4, 5], times: ['11:00'] };
it('puts weekday nets on Amsterdam time across the DST switch', () => {
// Sat 24 Oct 2026 .. Sat 31 Oct 2026; DST ends Sun 25 Oct.
const occ = occurrences(
ronde,
new Date('2026-10-24T00:00:00Z'),
new Date('2026-10-31T00:00:00Z')
);
expect(occ.map((d) => d.toISOString())).toEqual([
'2026-10-26T10:00:00.000Z',
'2026-10-27T10:00:00.000Z',
'2026-10-28T10:00:00.000Z',
'2026-10-29T10:00:00.000Z',
'2026-10-30T10:00:00.000Z'
]);
const summer = occurrences(
ronde,
new Date('2026-07-01T00:00:00Z'),
new Date('2026-07-02T00:00:00Z')
);
expect(summer[0].toISOString()).toBe('2026-07-01T09:00:00.000Z');
});
it('finds the next occurrence and skips nets without a time', () => {
expect(nextOccurrence(ronde, new Date('2026-10-30T10:30:00Z'))?.toISOString()).toBe(
'2026-11-02T10:00:00.000Z'
);
expect(nextOccurrence({ id: 2, days: [7], times: [] }, new Date())).toBeNull();
});
});
describe('scanner', () => {
it('maps channels to 10 banks of 50', () => {
expect(bankOf(1)).toBe(1);
expect(bankOf(50)).toBe(1);
expect(bankOf(51)).toBe(2);
expect(bankOf(500)).toBe(10);
});
it('exports all 500 channels in BC125AT_SS order', () => {
const csv = scannerCsv([
{
channel: 301,
alphaTag: 'KUSTWACHT, 16',
freqHz: 156_800_000,
mode: 'FM',
delay: true,
lockout: false,
priority: true
}
]);
const lines = csv.trimEnd().split('\r\n');
expect(lines).toHaveLength(501);
expect(lines[0]).toBe(
'Bank,Channel,Name,Frequency,Modulation,CTCSS/DCS,Delay,Lockout,Priority'
);
expect(lines[301]).toBe('7,301,"KUSTWACHT, 16",156.8000,FM,OFF,2,OFF,ON');
expect(lines[1]).toBe('1,1,,,AUTO,OFF,2,OFF,OFF');
});
it('keeps alpha tags within 16 characters', () => {
expect(
alphaTag('Kustwacht Wal Scheveningen weerbericht en navigatie').length
).toBeLessThanOrEqual(16);
});
});
describe('quiet hours', () => {
const q = parseQuiet('00:00-08:00');
it('parses and checks Amsterdam wall time', () => {
expect(q?.label).toBe('00:00–08:00');
expect(parseQuiet('off')).toBeNull();
expect(inQuiet(fromZoned(2026, 10, 27, 2, 11), q)).toBe(true);
expect(inQuiet(fromZoned(2026, 10, 27, 7, 1), q)).toBe(true);
expect(inQuiet(fromZoned(2026, 10, 27, 8, 0), q)).toBe(false);
expect(inQuiet(fromZoned(2026, 10, 26, 23, 59), q)).toBe(false);
expect(inQuiet(fromZoned(2026, 10, 26, 18, 55), null)).toBe(false);
});
it('wraps midnight', () => {
const w = parseQuiet('23:00-07:30');
expect(inQuiet(fromZoned(2026, 10, 26, 23, 0), w)).toBe(true);
expect(inQuiet(fromZoned(2026, 10, 27, 7, 30), w)).toBe(false);
});
});

336
src/lib/radio/index.ts Normal file
View File

@@ -0,0 +1,336 @@
// Radio tracker (/trackers/radio, owner-only): pure helpers shared by the page and
// scripts/radio.ts (ISS refresh + reminders). No server imports here: the script runs inside the
// app container, which ships src/lib/radio (see Dockerfile).
export const TZ = 'Europe/Amsterdam';
export const BANDS = ['marine', 'air', 'ham', 'pmr', 'other'] as const;
export type Band = (typeof BANDS)[number];
export const BAND_LABEL: Record<Band, string> = {
marine: 'marine',
air: 'airband',
ham: 'ham',
pmr: 'PMR',
other: 'other'
};
/** Gradient per band: colourful icons on a black-and-white page. */
export const BAND_COLOR: Record<Band, [string, string]> = {
marine: ['#38bdf8', '#0369a1'],
air: ['#a78bfa', '#5b21b6'],
ham: ['#fb7185', '#be123c'],
pmr: ['#fbbf24', '#b45309'],
other: ['#a1a1aa', '#3f3f46']
};
export const MODES = ['FM', 'NFM', 'AM', 'USB', 'LSB', 'CW', 'DATA'] as const;
export type Mode = (typeof MODES)[number];
/** Modes the UBC125XLT can store (AUTO aside); others export as AUTO. */
export const SCANNER_MODES = new Set<string>(['FM', 'NFM', 'AM']);
/** Uniden UBC125XLT layout: 10 banks of 50 channels. */
export const BANKS = 10;
export const PER_BANK = 50;
export const CHANNELS = BANKS * PER_BANK;
export const bankOf = (channel: number) => Math.floor((channel - 1) / PER_BANK) + 1;
export const bankRange = (bank: number) => [(bank - 1) * PER_BANK + 1, bank * PER_BANK] as const;
/** The UBC125XLT shows 16-character alpha tags. */
export const ALPHA_TAG_MAX = 16;
/** Minimum max-elevation for an ISS pass to count (shown highlighted, reminded). */
export const ISS_MIN_EL = 10;
export const REMIND_MINUTES = 10;
export const ISS_DOWNLINK_HZ = 145_800_000;
/** No reminders are sent when the send time falls in this Amsterdam window (RADIO_QUIET_HOURS overrides). */
export const QUIET_HOURS = '00:00-08:00';
export type Quiet = { from: number; to: number; label: string } | null;
/** "00:00-08:00" -> minutes of day; "off" / "" -> null (no quiet hours). */
export function parseQuiet(spec: string | null | undefined): Quiet {
const m = /^\s*(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})\s*$/.exec(spec ?? '');
if (!m) return null;
const from = Number(m[1]) * 60 + Number(m[2]);
const to = Number(m[3]) * 60 + Number(m[4]);
if (from > 1440 || to > 1440 || from === to) return null;
const pad = (n: number) =>
`${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
return { from, to, label: `${pad(from)}–${pad(to)}` };
}
export const DAY_SHORT = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
/** 145450000 -> "145.4500" (MHz, 4 decimals like the scanner); below 30 MHz -> "3785 kHz". */
export function formatFreq(hz: number | null | undefined, unit = true): string {
if (hz == null) return '';
if (hz < 30_000_000) {
const khz = hz / 1000;
return `${Number.isInteger(khz) ? khz : khz.toFixed(1)}${unit ? ' kHz' : ''}`;
}
return `${(hz / 1e6).toFixed(4)}${unit ? ' MHz' : ''}`;
}
/** MHz with 4 decimals, no unit (CSV/programming software). */
export const mhz = (hz: number) => (hz / 1e6).toFixed(4);
/**
* Parse a typed frequency: "145.45", "145.450 MHz", "3785 kHz", "3785k", "145450000".
* Bare numbers < 1000 are MHz, 1000..999999 kHz, larger Hz. Returns Hz or null.
*/
export function parseFreq(input: string | null | undefined): number | null {
if (!input) return null;
const s = input.trim().toLowerCase().replace(',', '.').replace(/\s+/g, '');
const m = s.match(/^(\d+(?:\.\d+)?)(mhz|m|khz|k|hz)?$/);
if (!m) return null;
const n = Number(m[1]);
if (!Number.isFinite(n) || n <= 0) return null;
const u = m[2];
let hz: number;
if (u === 'mhz' || u === 'm') hz = n * 1e6;
else if (u === 'khz' || u === 'k') hz = n * 1e3;
else if (u === 'hz') hz = n;
else if (n < 1000) hz = n * 1e6;
else if (n < 1_000_000) hz = n * 1e3;
else hz = n;
hz = Math.round(hz);
return hz >= 1000 && hz <= 10_000_000_000 ? hz : null;
}
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
export const compass = (az: number) => COMPASS[Math.round((((az % 360) + 360) % 360) / 45) % 8];
// ---- time zones -------------------------------------------------------------------------------
const partsFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
weekday: 'short'
});
const WD: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
/** Wall-clock parts of an instant in Europe/Amsterdam (isoDay 1 = Monday). */
export function zoned(at: Date) {
const p = Object.fromEntries(partsFmt.formatToParts(at).map((x) => [x.type, x.value]));
return {
y: Number(p.year),
m: Number(p.month),
d: Number(p.day),
h: Number(p.hour),
min: Number(p.minute),
s: Number(p.second),
isoDay: WD[p.weekday as string] ?? 1
};
}
/** The instant at which Europe/Amsterdam's wall clock reads y-m-d h:min. */
export function fromZoned(y: number, m: number, d: number, h: number, min: number): Date {
const guess = Date.UTC(y, m - 1, d, h, min);
// Offset at the guess, then correct once more for DST edges.
let t = guess;
for (let i = 0; i < 2; i++) {
const z = zoned(new Date(t));
const asUtc = Date.UTC(z.y, z.m - 1, z.d, z.h, z.min, z.s);
t = guess - (asUtc - t);
}
return new Date(t);
}
/** "11:00" / "11:00:00" -> [11, 0]. */
export const hm = (t: string) => {
const [h, m] = t.split(':').map(Number);
return [h || 0, m || 0] as const;
};
export type NetSchedule = { id: number; days: number[]; times: string[]; active?: boolean };
/** Occurrences of a weekly schedule (Amsterdam wall time) between from and to. */
export function occurrences(net: NetSchedule, from: Date, to: Date): Date[] {
if (net.active === false || !net.days.length || !net.times.length) return [];
const out: Date[] = [];
const start = zoned(new Date(from.getTime() - 86_400_000));
// Walk calendar days in Amsterdam (via noon UTC so DST never skips a day).
let cursor = Date.UTC(start.y, start.m - 1, start.d, 12);
while (cursor <= to.getTime() + 86_400_000) {
const dt = new Date(cursor);
const y = dt.getUTCFullYear();
const m = dt.getUTCMonth() + 1;
const d = dt.getUTCDate();
const isoDay = ((dt.getUTCDay() + 6) % 7) + 1;
if (net.days.includes(isoDay)) {
for (const t of net.times) {
const [h, mi] = hm(t);
const at = fromZoned(y, m, d, h, mi);
if (at > from && at <= to) out.push(at);
}
}
cursor += 86_400_000;
}
return out.sort((a, b) => a.getTime() - b.getTime());
}
export const nextOccurrence = (net: NetSchedule, now: Date) =>
occurrences(net, now, new Date(now.getTime() + 8 * 86_400_000))[0] ?? null;
const timeFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const dayFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
weekday: 'short',
day: 'numeric',
month: 'short'
});
export const fmtTime = (at: Date | string | number) => timeFmt.format(new Date(at));
export const fmtDay = (at: Date | string | number) => dayFmt.format(new Date(at));
/** Amsterdam calendar date "2026-10-26" (for grouping). */
export const dayKey = (at: Date | string | number) => {
const z = zoned(new Date(at));
return `${z.y}-${String(z.m).padStart(2, '0')}-${String(z.d).padStart(2, '0')}`;
};
/** "today", "tomorrow" or "Wed 28 Oct". */
export function relDay(at: Date | string | number, now: Date | number = Date.now()) {
const k = dayKey(at);
if (k === dayKey(now)) return 'today';
if (k === dayKey(new Date(now).getTime() + 86_400_000)) return 'tomorrow';
return fmtDay(at);
}
/** "in 5 min", "in 2 h 10 min", "now". */
export function until(at: Date | string | number, now: Date | number = Date.now()) {
const mins = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 60_000);
if (mins <= 0) return 'now';
if (mins < 60) return `in ${mins} min`;
const h = Math.floor(mins / 60);
if (h < 24) return `in ${h} h${mins % 60 ? ` ${mins % 60} min` : ''}`;
const d = Math.round(h / 24);
return `in ${d} day${d === 1 ? '' : 's'}`;
}
/** Days as text: [1,2,3,4,5] -> "weekdays", all 7 -> "daily", else "mon, wed". */
export function daysLabel(days: number[]) {
const s = [...new Set(days)].sort();
if (s.length === 7) return 'daily';
if (s.join() === '1,2,3,4,5') return 'weekdays';
if (s.join() === '6,7') return 'weekends';
return s.map((d) => DAY_SHORT[d - 1]).join(', ');
}
// ---- scanner CSV ------------------------------------------------------------------------------
export type ChannelRow = {
channel: number;
alphaTag: string | null;
freqHz: number | null;
mode: string | null;
delay: boolean;
lockout: boolean;
priority: boolean;
};
const csvCell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
/**
* All 500 channels as CSV in the column order of Uniden's BC125AT/UBC125XLT programming software
* (BC125AT_SS: Channel, Name, Frequency, Modulation, CTCSS/DCS, Delay, Lockout, Priority) plus a
* leading Bank column. Empty channels are blank rows so the file always maps 1:1 to memories.
*/
export function scannerCsv(rows: ChannelRow[]): string {
const by = new Map(rows.map((r) => [r.channel, r]));
const lines = [
[
'Bank',
'Channel',
'Name',
'Frequency',
'Modulation',
'CTCSS/DCS',
'Delay',
'Lockout',
'Priority'
]
.map(csvCell)
.join(',')
];
for (let ch = 1; ch <= CHANNELS; ch++) {
const r = by.get(ch);
const has = r && r.freqHz != null;
lines.push(
[
bankOf(ch),
ch,
has ? (r.alphaTag ?? '').slice(0, ALPHA_TAG_MAX) : '',
has ? mhz(r.freqHz!) : '',
has ? (SCANNER_MODES.has(r.mode ?? '') ? r.mode! : 'AUTO') : 'AUTO',
'OFF',
has ? (r.delay ? 2 : 0) : 2,
has ? (r.lockout ? 'ON' : 'OFF') : 'OFF',
has ? (r.priority ? 'ON' : 'OFF') : 'OFF'
]
.map(csvCell)
.join(',')
);
}
return lines.join('\r\n') + '\r\n';
}
/** Short scanner tag from a station name (abbreviations, then cut to 16 characters). */
export function alphaTag(name: string, marineCh?: string | null): string {
const ABBR: [RegExp, string][] = [
[/\bSchiphol\b/gi, 'SPL'],
[/\bRotterdam\b/gi, 'RTM'],
[/\bScheveningen\b/gi, 'SCHEV'],
[/\bMaastricht\b/gi, 'MUAC'],
[/\bBrussel\b/gi, 'BRU'],
[/\bLondon\b/gi, 'LON'],
[/\bNieuw Milligen\b/gi, 'MIL'],
[/\bDordrecht\b/gi, 'DORD'],
[/\bKustwacht\b/gi, 'KWC'],
[/\bTower\b|\bToren\b/gi, 'TWR'],
[/\bApproach\b/gi, 'APP'],
[/\bArrival\b/gi, 'ARR'],
[/\bDeparture\b/gi, 'DEP'],
[/\bRadar\b/gi, 'RDR'],
[/\bOperations\b/gi, 'OPS'],
[/\bEurocontrol\b/gi, ''],
[/\bArea Control\b/gi, 'AC'],
[/\bSector\b/gi, ''],
[/\bBaan\b/gi, ''],
[/\bZweefvliegtuigen\b/gi, 'Gliders'],
[/\bInternational(e)?\b|\bInternationaal\b/gi, 'Intl'],
[/\bEmergency Frequency\b/gi, 'Emergency'],
[/\bReddings\s*Brigade\b/gi, 'RB'],
[/\bSluizen\b/gi, ''],
[/\bVolkeraksluizen\b/gi, 'Volkerak'],
[/\bSleep Dienst\b/gi, 'Tug'],
[/\bScheepvaartbedrijven\b|\bScheepvaart bedrijven\b/gi, 'Shipping'],
[/\bRijkswaterstaat\b/gi, 'RWS'],
[/\bLandelijk Scheepsportofoon\b/gi, 'Ship UHF'],
[/\bKanaal\b/gi, 'ch'],
[/\bSchip\b/gi, 'S'],
[/\bWal\b/gi, 'W']
];
let s = name;
for (const [re, to] of ABBR) s = s.replace(re, to);
s = s
.replace(/\([^)]*\)/g, '')
.replace(/\s+/g, ' ')
.trim();
if (marineCh) s = `${marineCh} ${s}`;
return s.slice(0, ALPHA_TAG_MAX).trim();
}
/** Whether an instant falls in the quiet window (Amsterdam wall clock; the window may wrap midnight). */
export function inQuiet(at: Date, quiet: Quiet): boolean {
if (!quiet) return false;
const z = zoned(at);
const t = z.h * 60 + z.min;
return quiet.from < quiet.to ? t >= quiet.from && t < quiet.to : t >= quiet.from || t < quiet.to;
}

View File

@@ -122,7 +122,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.'
},
'/stats/opinions/world': {
'/stats/world/politics': {
title: 'World Opinions',
description:
'How political words, parties, values and national histories differ across the world.'

217
src/lib/server/ai-setup.ts Normal file
View File

@@ -0,0 +1,217 @@
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
// each with entries grouped by section. Labels only: values that look like secrets are refused.
import { db } from '#lib/server/db/index.js';
import {
AI_SETUP_COLORS,
AI_SETUP_SCOPES,
AI_SETUP_SECTIONS,
aiSetupEntries,
aiSetupProviders
} from '#lib/server/db/schema/index.js';
import { asc, eq, sql } from 'drizzle-orm';
import { Marked } from 'marked';
import * as z from 'zod';
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const marked = new Marked({
gfm: true,
breaks: true,
renderer: {
html: ({ text }) => escapeHtml(text),
link({ href, title, tokens }) {
const label = this.parser.parseInline(tokens);
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
const t = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
},
image: ({ text }) => escapeHtml(text)
}
});
const renderBody = (md: string) => marked.parse(md, { async: false });
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
const SECRET =
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
const optional = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => (v ? v : null));
const idField = z.coerce.number().int().positive();
const providerInput = z.object({
name: trimmed(60),
products: optional(120),
color: z.enum(AI_SETUP_COLORS).catch('zinc')
});
const entryInput = z
.object({
section: z.enum(AI_SETUP_SECTIONS),
name: trimmed(120),
note: optional(400),
scope: z
.enum(['', ...AI_SETUP_SCOPES])
.nullish()
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
body: optional(20000)
})
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
message: SECRET_MSG
});
export type SetupEntry = {
id: number;
section: (typeof AI_SETUP_SECTIONS)[number];
name: string;
note: string | null;
scope: string | null;
link: string | null;
body: string | null;
bodyHtml: string | null;
updatedAt: string;
};
export type SetupProvider = {
id: number;
name: string;
products: string | null;
color: (typeof AI_SETUP_COLORS)[number];
updatedAt: string;
entries: SetupEntry[];
};
export async function loadSetup(): Promise<SetupProvider[]> {
const providers = await db
.select()
.from(aiSetupProviders)
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
const entries = await db
.select()
.from(aiSetupEntries)
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
return providers.map((p) => {
const own = entries.filter((e) => e.providerId === p.id);
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
return {
id: p.id,
name: p.name,
products: p.products,
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
? (p.color as SetupProvider['color'])
: 'zinc',
updatedAt: new Date(latest).toISOString(),
entries: own.map((e) => ({
id: e.id,
section: e.section as SetupEntry['section'],
name: e.name,
note: e.note,
scope: e.scope,
link: e.link,
body: e.body,
bodyHtml: e.body ? renderBody(e.body) : null,
updatedAt: e.updatedAt.toISOString()
}))
};
});
}
type Result = { ok: true } | { ok: false; message: string };
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
const fields = (form: FormData) =>
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
const touch = (providerId: number) =>
db
.update(aiSetupProviders)
.set({ updatedAt: new Date() })
.where(eq(aiSetupProviders.id, providerId));
const isUniqueViolation = (e: unknown) =>
typeof e === 'object' &&
e !== null &&
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
export async function addProvider(form: FormData): Promise<Result> {
const parsed = providerInput.safeParse(fields(form));
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
try {
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
.from(aiSetupProviders);
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
} catch (e) {
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
throw e;
}
return { ok: true };
}
export async function updateProvider(form: FormData): Promise<Result> {
const id = idField.safeParse(form.get('id'));
const parsed = providerInput.safeParse(fields(form));
if (!id.success) return { ok: false, message: 'Missing id' };
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
try {
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
} catch (e) {
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
throw e;
}
return { ok: true };
}
export async function deleteProvider(form: FormData): Promise<Result> {
const id = idField.safeParse(form.get('id'));
if (!id.success) return { ok: false, message: 'Missing id' };
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
return { ok: true };
}
export async function addEntry(form: FormData): Promise<Result> {
const providerId = idField.safeParse(form.get('provider_id'));
const parsed = entryInput.safeParse(fields(form));
if (!providerId.success) return { ok: false, message: 'Missing provider' };
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
.from(aiSetupEntries)
.where(eq(aiSetupEntries.providerId, providerId.data));
await db
.insert(aiSetupEntries)
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
await touch(providerId.data);
return { ok: true };
}
export async function updateEntry(form: FormData): Promise<Result> {
const id = idField.safeParse(form.get('id'));
const parsed = entryInput.safeParse(fields(form));
if (!id.success) return { ok: false, message: 'Missing id' };
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
const [row] = await db
.update(aiSetupEntries)
.set(parsed.data)
.where(eq(aiSetupEntries.id, id.data))
.returning({ providerId: aiSetupEntries.providerId });
if (row) await touch(row.providerId);
return { ok: true };
}
export async function deleteEntry(form: FormData): Promise<Result> {
const id = idField.safeParse(form.get('id'));
if (!id.success) return { ok: false, message: 'Missing id' };
const [row] = await db
.delete(aiSetupEntries)
.where(eq(aiSetupEntries.id, id.data))
.returning({ providerId: aiSetupEntries.providerId });
if (row) await touch(row.providerId);
return { ok: true };
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')`
)
]
);

View File

@@ -0,0 +1,186 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
check,
index,
integer,
primaryKey,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { radioSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Radio tracker (/trackers/radio, owner-only): frequencies, the scanner's memory banks (Uniden
// UBC125XLT, 10 x 50 channels), nets and broadcast schedules, ISS passes over Gouda and a
// listening log. scripts/radio.ts refreshes the ISS passes (daily timer) and sends the ntfy
// reminders (every-minute timer). Bands, modes and the bank layout live in #lib/radio.
export const radioFrequencies = radioSchema.table(
'frequencies',
{
id: id(),
freqHz: bigint('freq_hz', { mode: 'number' }).notNull(),
/** FM | NFM | AM | USB | LSB | CW | DATA */
mode: text('mode').notNull().default('FM'),
/** marine | air | ham | pmr | other */
band: text('band').notNull(),
name: text('name').notNull(),
location: text('location'),
notes: text('notes'),
/** Marine VHF channel number ("16", "83"), when it is one. */
marineCh: text('marine_ch'),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
source: text('source'),
...timestamps
},
(t) => [
uniqueIndex('frequencies_freq_name_key').on(t.freqHz, t.name),
index('frequencies_band_idx').on(t.band),
check('frequencies_mode_check', sql`${t.mode} IN ('FM','NFM','AM','USB','LSB','CW','DATA')`),
check('frequencies_band_check', sql`${t.band} IN ('marine','air','ham','pmr','other')`),
check('frequencies_freq_check', sql`${t.freqHz} > 0`)
]
);
/** Scanner memories: channel 1..500 (bank = (channel - 1) / 50 + 1). */
export const radioChannels = radioSchema.table(
'channels',
{
channel: smallint('channel').primaryKey(),
frequencyId: integer('frequency_id')
.notNull()
.references(() => radioFrequencies.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** Up to 16 characters on the UBC125XLT; null = derived from the frequency's name. */
alphaTag: text('alpha_tag'),
delay: boolean('delay').notNull().default(true),
lockout: boolean('lockout').notNull().default(false),
priority: boolean('priority').notNull().default(false),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
},
(t) => [
index('channels_frequency_idx').on(t.frequencyId),
check('channels_channel_check', sql`${t.channel} BETWEEN 1 AND 500`),
check('channels_alpha_tag_check', sql`char_length(${t.alphaTag}) <= 16`)
]
);
/** Nets and broadcasts on a weekly schedule, in Europe/Amsterdam wall time. */
export const radioNets = radioSchema.table(
'nets',
{
id: id(),
name: text('name').notNull(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** ISO weekdays, 1 = Monday .. 7 = Sunday. */
days: smallint('days')
.array()
.notNull()
.default(sql`'{}'::smallint[]`),
/** "HH:MM" start times; empty = time unknown (listed, never reminded). */
times: text('times')
.array()
.notNull()
.default(sql`'{}'::text[]`),
durationMin: integer('duration_min'),
kind: text('kind').notNull().default('net'),
notes: text('notes'),
/** ntfy reminder 10 minutes before each start. */
remind: boolean('remind').notNull().default(true),
active: boolean('active').notNull().default(true),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
check('nets_kind_check', sql`${t.kind} IN ('net','broadcast','other')`),
check('nets_days_check', sql`${t.days} <@ '{1,2,3,4,5,6,7}'::smallint[]`),
check(
'nets_times_check',
sql`array_to_string(${t.times}, ',') ~ '^(([01][0-9]|2[0-3]):[0-5][0-9](,|$))*$'`
)
]
);
/** ISS passes over Gouda (52.011 N, 4.711 E), from Celestrak's TLE, refreshed daily. */
export const radioIssPasses = radioSchema.table(
'iss_passes',
{
startAt: timestamptz('start_at').primaryKey(),
maxAt: timestamptz('max_at').notNull(),
endAt: timestamptz('end_at').notNull(),
/** Degrees above the horizon. */
maxEl: real('max_el').notNull(),
/** Azimuths in degrees from north. */
startAz: real('start_az').notNull(),
maxAz: real('max_az').notNull(),
endAz: real('end_az').notNull(),
/** The ISS is sunlit while the observer's sky is dark at max (naked-eye visible). */
sunlit: boolean('sunlit').notNull().default(false),
computedAt: timestamptz('computed_at').notNull().defaultNow()
},
(t) => [index('iss_passes_end_idx').on(t.endAt)]
);
/** The TLE used for the passes (one row). */
export const radioIssTle = radioSchema.table(
'iss_tle',
{
id: smallint('id').primaryKey().default(1),
name: text('name').notNull(),
line1: text('line1').notNull(),
line2: text('line2').notNull(),
epoch: timestamptz('epoch').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
},
(t) => [check('iss_tle_single_check', sql`${t.id} = 1`)]
);
/** Listening log. */
export const radioLog = radioSchema.table(
'log',
{
id: id(),
heardAt: timestamptz('heard_at').notNull().defaultNow(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** What was tuned (kept even if the frequency row is deleted, or for one-offs). */
freqHz: bigint('freq_hz', { mode: 'number' }),
mode: text('mode'),
what: text('what').notNull(),
/** 1 (barely) .. 5 (loud and clear). */
signal: smallint('signal'),
notes: text('notes'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('log_heard_idx').on(t.heardAt),
index('log_frequency_idx').on(t.frequencyId),
check('log_signal_check', sql`${t.signal} BETWEEN 1 AND 5`)
]
);
/** Reminders already sent (kind 'net' + net id, or 'iss' + 0), so each fires once. */
export const radioReminders = radioSchema.table(
'reminders',
{
kind: text('kind').notNull(),
refId: integer('ref_id').notNull(),
at: timestamptz('at').notNull(),
sentAt: timestamptz('sent_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.kind, t.refId, t.at] }),
check('reminders_kind_check', sql`${t.kind} IN ('net','iss')`)
]
);

View File

@@ -29,3 +29,6 @@ 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');
export const radioSchema = pgSchema('radio');

View File

@@ -523,7 +523,7 @@ export const statsElecTurnout = statsSchema.table(
);
// ── Step 3: world opinions (§4, A15) ─────────────────────────────────────────────────────
// The /stats/opinions/world guide reads these. Curated rows (seeded from the former
// The /stats/world/politics guide reads these. Curated rows (seeded from the former
// src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
export const statsWoCountry = statsSchema.table('wo_country', {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
import { MATH_PRIVATE } from '#lib/math.js';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
@@ -37,6 +38,26 @@ 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('gates the whole radio tracker, reads, writes and the CSV export', () => {
expect(isOwnerOnlyPath('/trackers/radio')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radiox')).toBe(false);
expect(isOwnerWritePath('/trackers/radio')).toBe(true);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);

View File

@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { MATH_PRIVATE } from '#lib/math.js';
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
@@ -29,15 +30,22 @@ 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',
// Radio tracker: Opa's scanner frequencies, banks, nets, ISS passes and my log. Mine only.
'/trackers/radio',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
/**
@@ -66,7 +74,10 @@ export const ownerWritePrefixes = [
'/trackers/pc',
'/trackers/notes',
'/trackers/ai',
'/trackers/movies'
'/trackers/movies',
'/trackers/math',
'/trackers/cargill',
'/trackers/radio'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

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

373
src/lib/server/radio.ts Normal file
View File

@@ -0,0 +1,373 @@
// Radio tracker (/trackers/radio, owner-only): loads everything for the page and handles the
// owner's edits. Hand-written SQL on the shared postgres.js client; bigint Hz come back as
// float8 so they are plain numbers.
import { client } from '#lib/server/db/index.js';
import {
ALPHA_TAG_MAX,
BANDS,
CHANNELS,
MODES,
alphaTag,
fromZoned,
parseFreq,
type ChannelRow
} from '#lib/radio/index.js';
export type Frequency = {
id: number;
freqHz: number;
mode: string;
band: string;
name: string;
location: string | null;
notes: string | null;
marineCh: string | null;
tags: string[];
channels: number[];
logCount: number;
};
export type Channel = {
channel: number;
frequencyId: number;
alphaTag: string | null;
tag: string;
delay: boolean;
lockout: boolean;
priority: boolean;
};
export type Net = {
id: number;
name: string;
frequencyId: number | null;
days: number[];
times: string[];
durationMin: number | null;
kind: string;
notes: string | null;
remind: boolean;
active: boolean;
};
export type IssPass = {
startAt: string;
maxAt: string;
endAt: string;
maxEl: number;
startAz: number;
maxAz: number;
endAz: number;
sunlit: boolean;
};
export type LogEntry = {
id: number;
heardAt: string;
frequencyId: number | null;
freqHz: number | null;
mode: string | null;
what: string;
signal: number | null;
notes: string | null;
};
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
export async function loadRadio() {
const [freqs, chans, nets, passes, [tle], log, [logTotal]] = await Promise.all([
client`SELECT f.id, f.freq_hz::float8 AS freq_hz, f.mode, f.band, f.name, f.location, f.notes,
f.marine_ch, f.tags,
coalesce((SELECT array_agg(c.channel ORDER BY c.channel) FROM radio.channels c
WHERE c.frequency_id = f.id), '{}') AS channels,
(SELECT count(*)::int FROM radio.log l WHERE l.frequency_id = f.id) AS log_count
FROM radio.frequencies f ORDER BY f.freq_hz, f.name`,
client`SELECT channel, frequency_id, alpha_tag, delay, lockout, priority
FROM radio.channels ORDER BY channel`,
client`SELECT id, name, frequency_id, days, times, duration_min, kind, notes, remind, active
FROM radio.nets ORDER BY sort_order, id`,
client`SELECT start_at, max_at, end_at, max_el, start_az, max_az, end_az, sunlit
FROM radio.iss_passes WHERE end_at > now() ORDER BY start_at LIMIT 60`,
client`SELECT name, epoch, fetched_at FROM radio.iss_tle WHERE id = 1`,
client`SELECT id, heard_at, frequency_id, freq_hz::float8 AS freq_hz, mode, what, signal, notes
FROM radio.log ORDER BY heard_at DESC, id DESC LIMIT 300`,
client`SELECT count(*)::int AS n FROM radio.log`
]);
const frequencies: Frequency[] = freqs.map((f) => ({
id: f.id,
freqHz: f.freq_hz,
mode: f.mode,
band: f.band,
name: f.name,
location: f.location,
notes: f.notes,
marineCh: f.marine_ch,
tags: f.tags ?? [],
channels: (f.channels ?? []).map(Number),
logCount: f.log_count
}));
const byId = new Map(frequencies.map((f) => [f.id, f]));
const channels: Channel[] = chans.map((c) => {
const f = byId.get(c.frequency_id);
return {
channel: c.channel,
frequencyId: c.frequency_id,
alphaTag: c.alpha_tag,
tag: c.alpha_tag ?? (f ? alphaTag(f.name, f.marineCh) : ''),
delay: c.delay,
lockout: c.lockout,
priority: c.priority
};
});
return {
frequencies,
channels,
nets: nets.map((n): Net => ({
id: n.id,
name: n.name,
frequencyId: n.frequency_id,
days: (n.days ?? []).map(Number),
times: n.times ?? [],
durationMin: n.duration_min,
kind: n.kind,
notes: n.notes,
remind: n.remind,
active: n.active
})),
passes: passes.map((p): IssPass => ({
startAt: iso(p.start_at)!,
maxAt: iso(p.max_at)!,
endAt: iso(p.end_at)!,
maxEl: p.max_el,
startAz: p.start_az,
maxAz: p.max_az,
endAz: p.end_az,
sunlit: p.sunlit
})),
tle: tle ? { name: tle.name, epoch: iso(tle.epoch), fetchedAt: iso(tle.fetched_at) } : null,
log: log.map((l): LogEntry => ({
id: l.id,
heardAt: iso(l.heard_at)!,
frequencyId: l.frequency_id,
freqHz: l.freq_hz,
mode: l.mode,
what: l.what,
signal: l.signal,
notes: l.notes
})),
logTotal: logTotal.n as number
};
}
/** All channels with their frequency, for the CSV export. */
export async function channelRows(): Promise<ChannelRow[]> {
const rows = await client`SELECT c.channel, c.alpha_tag, c.delay, c.lockout, c.priority,
f.freq_hz::float8 AS freq_hz, f.mode, f.name, f.marine_ch
FROM radio.channels c JOIN radio.frequencies f ON f.id = c.frequency_id ORDER BY c.channel`;
return rows.map((r) => ({
channel: r.channel,
alphaTag: r.alpha_tag ?? alphaTag(r.name, r.marine_ch),
freqHz: r.freq_hz,
mode: r.mode,
delay: r.delay,
lockout: r.lockout,
priority: r.priority
}));
}
// ---- actions ----------------------------------------------------------------------------------
type Result = { ok: true; message?: string } | { ok: false; message: string };
const str = (form: FormData, k: string) => {
const v = form.get(k);
return typeof v === 'string' && v.trim() ? v.trim() : null;
};
const int = (form: FormData, k: string) => {
const v = Number(str(form, k));
return Number.isInteger(v) && v > 0 ? v : null;
};
const tagsOf = (s: string | null) =>
s
? [
...new Set(
s
.split(/[,\s]+/)
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
)
]
: [];
export async function saveFrequency(form: FormData): Promise<Result> {
const id = int(form, 'id');
const freqHz = parseFreq(str(form, 'freq'));
if (!freqHz) return { ok: false, message: 'Frequency not understood (try 145.450 or 3785 kHz).' };
const name = str(form, 'name');
if (!name) return { ok: false, message: 'A name is needed.' };
const mode = (str(form, 'mode') ?? 'FM').toUpperCase();
if (!(MODES as readonly string[]).includes(mode)) return { ok: false, message: 'Unknown mode.' };
const band = str(form, 'band') ?? 'other';
if (!(BANDS as readonly string[]).includes(band)) return { ok: false, message: 'Unknown band.' };
const v = {
freq_hz: freqHz,
mode,
band,
name,
location: str(form, 'location'),
notes: str(form, 'notes'),
marine_ch: str(form, 'marine_ch'),
tags: tagsOf(str(form, 'tags'))
};
try {
if (id) {
await client`UPDATE radio.frequencies SET ${client(v)}, updated_at = now() WHERE id = ${id}`;
} else {
await client`INSERT INTO radio.frequencies ${client({ ...v, source: 'Stan' })}`;
}
} catch (e) {
if ((e as { code?: string }).code === '23505')
return { ok: false, message: 'That frequency with that name already exists.' };
throw e;
}
return { ok: true };
}
export async function deleteFrequency(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.frequencies WHERE id = ${id}`;
return { ok: true };
}
export async function setChannel(form: FormData): Promise<Result> {
const channel = int(form, 'channel');
if (!channel || channel > CHANNELS) return { ok: false, message: 'Channel must be 1-500.' };
if (form.get('clear') === '1') {
await client`DELETE FROM radio.channels WHERE channel = ${channel}`;
return { ok: true };
}
const frequencyId = int(form, 'frequency_id');
if (!frequencyId) return { ok: false, message: 'Pick a frequency.' };
const tag = str(form, 'alpha_tag');
const v = {
channel,
frequency_id: frequencyId,
alpha_tag: tag ? tag.slice(0, ALPHA_TAG_MAX) : null,
delay: form.get('delay') === 'on',
lockout: form.get('lockout') === 'on',
priority: form.get('priority') === 'on'
};
await client`INSERT INTO radio.channels ${client(v)}
ON CONFLICT (channel) DO UPDATE SET frequency_id = EXCLUDED.frequency_id,
alpha_tag = EXCLUDED.alpha_tag, delay = EXCLUDED.delay, lockout = EXCLUDED.lockout,
priority = EXCLUDED.priority, updated_at = now()`;
return { ok: true };
}
const TIME = /^([01]?\d|2[0-3])[:.]?([0-5]\d)$/;
export async function saveNet(form: FormData): Promise<Result> {
const id = int(form, 'id');
const name = str(form, 'name');
if (!name) return { ok: false, message: 'A name is needed.' };
const days = form
.getAll('days')
.map(Number)
.filter((d) => d >= 1 && d <= 7);
const times: string[] = [];
for (const t of (str(form, 'times') ?? '').split(/[,\s]+/).filter(Boolean)) {
const m = t.match(TIME);
if (!m) return { ok: false, message: `Time "${t}" not understood (use 11:00).` };
times.push(`${m[1].padStart(2, '0')}:${m[2]}`);
}
const kind = str(form, 'kind') ?? 'net';
const v = {
name,
frequency_id: int(form, 'frequency_id'),
days: [...new Set(days)].sort(),
times: [...new Set(times)].sort(),
duration_min: int(form, 'duration_min'),
kind: ['net', 'broadcast', 'other'].includes(kind) ? kind : 'net',
notes: str(form, 'notes'),
remind: form.get('remind') === 'on',
active: form.get('active') !== 'off'
};
if (id) await client`UPDATE radio.nets SET ${client(v)}, updated_at = now() WHERE id = ${id}`;
else await client`INSERT INTO radio.nets ${client(v)}`;
return { ok: true };
}
export async function toggleRemind(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`UPDATE radio.nets SET remind = NOT remind, updated_at = now() WHERE id = ${id}`;
return { ok: true };
}
export async function deleteNet(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.nets WHERE id = ${id}`;
return { ok: true };
}
/**
* Quick log: `freq` may be a frequency ("145.45"), a scanner channel ("ch 353"), or empty when a
* frequency_id is picked. A typed frequency links to the known frequency with that value.
*/
export async function addLog(form: FormData): Promise<Result> {
const what = str(form, 'what');
if (!what) return { ok: false, message: 'What did you hear?' };
let frequencyId = int(form, 'frequency_id');
let freqHz: number | null = null;
let mode: string | null = str(form, 'mode');
const typed = str(form, 'freq');
if (typed && !frequencyId) {
const ch = typed.match(/^ch\s*(\d{1,3})$/i);
if (ch) {
// A scanner memory first; banks 1-3 are empty, so "ch 16" there means marine channel 16.
const [r] =
await client`SELECT frequency_id FROM radio.channels WHERE channel = ${Number(ch[1])}`;
const [m] = r
? [null]
: await client`SELECT id FROM radio.frequencies WHERE marine_ch = ${ch[1]}
ORDER BY (SELECT count(*) FROM radio.channels c WHERE c.frequency_id = id) DESC, id LIMIT 1`;
if (!r && !m) return { ok: false, message: `Channel ${ch[1]} is empty.` };
frequencyId = r ? r.frequency_id : m!.id;
} else {
freqHz = parseFreq(typed);
if (!freqHz) return { ok: false, message: 'Frequency not understood.' };
const [r] = await client`SELECT id FROM radio.frequencies WHERE freq_hz = ${freqHz}
ORDER BY (SELECT count(*) FROM radio.channels c WHERE c.frequency_id = id) DESC, id LIMIT 1`;
if (r) frequencyId = r.id;
}
}
if (frequencyId) {
const [f] =
await client`SELECT freq_hz::float8 AS hz, mode FROM radio.frequencies WHERE id = ${frequencyId}`;
if (!f) return { ok: false, message: 'Unknown frequency.' };
freqHz = f.hz;
mode ??= f.mode;
}
const signal = int(form, 'signal');
// heard_at: "2026-10-26T18:05" from a datetime-local input is Amsterdam wall time.
const when = str(form, 'heard_at');
let heardAt = new Date();
if (when) {
const m = when.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return { ok: false, message: 'Time not understood.' };
heardAt = fromZoned(+m[1], +m[2], +m[3], +m[4], +m[5]);
}
await client`INSERT INTO radio.log ${client({
heard_at: heardAt.toISOString(),
frequency_id: frequencyId,
freq_hz: freqHz,
mode,
what,
signal: signal && signal <= 5 ? signal : null,
notes: str(form, 'notes')
})}`;
return { ok: true, message: 'Logged.' };
}
export async function deleteLog(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.log WHERE id = ${id}`;
return { ok: true };
}

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ export function loadHeritage(place: HeritageCode): Promise<Heritage> {
});
}
/** Long-run comparison series for /stats/history. */
/** Long-run comparison series for /stats/world/history. */
export function loadHistoryOverview() {
return cached('stats:history:overview', async () => {
const codes = ['NLD', 'USA', 'KOR', 'CUW', 'WLD'];

View File

@@ -0,0 +1,111 @@
// Server side of the /stats matrix: resolves a theme's metrics for the selected places to stored
// series (first candidate with data wins) and loads the rich views that fit the combination.
import { cached, loadSeries, type StatPoint } from '#lib/server/stats.js';
import {
candidates,
PLACE_BY_CODE,
type Metric,
type Selection,
type Theme,
type ViewSpec
} from '#lib/stats/matrix.js';
import * as views from '#lib/server/stats-views.js';
import type { CountryCode } from '#lib/stats/countries.js';
import type { HeritageCode } from '#lib/stats/heritage.js';
export type MetricLine = {
code: string;
seriesId: string;
sourceId: string;
freq: string;
unit: string;
points: StatPoint[];
};
export type MetricResult = {
metric: Metric;
lines: MetricLine[];
/** Selected places without any series for this metric. */
missing: string[];
};
export async function loadMetrics(sel: Selection, theme: Theme): Promise<MetricResult[]> {
const places = sel.codes.map((c) => PLACE_BY_CODE.get(c)!).filter(Boolean);
const reqs = new Map<string, { id: string; from?: string; resample?: 'M' }>();
for (const m of theme.metrics)
for (const p of places)
for (const id of candidates(m, p))
reqs.set(id, {
id,
...(m.from ? { from: m.from } : {}),
...(m.resample ? { resample: m.resample } : {})
});
const key = `stats:matrix:${theme.slug}:${sel.codes.join(',')}`;
const series = await cached(key, () => loadSeries([...reqs.values()]));
const today = new Date().toISOString().slice(0, 10);
return theme.metrics.map((m) => {
const lines: MetricLine[] = [];
const missing: string[] = [];
for (const p of places) {
const id = candidates(m, p).find((i) => series[i]?.points.length);
if (!id) {
missing.push(p.code);
continue;
}
const s = series[id];
// Drop forecasts (IMF WEO runs years ahead); the charts show what has happened.
const points = s.points.filter(([period]) => period <= today);
if (!points.length) {
missing.push(p.code);
continue;
}
lines.push({
code: p.code,
seriesId: id,
sourceId: s.sourceId,
freq: s.freq,
unit: s.unit,
points
});
}
return { metric: m, lines, missing };
});
}
export type LoadedView =
| { kind: 'economy'; data: views.EconomyViewData }
| { kind: 'energy'; data: views.EnergyViewData }
| { kind: 'gouda'; data: views.GoudaViewData }
| { kind: 'countries'; data: views.CountriesViewData }
| { kind: 'country'; data: views.CountryViewData }
| { kind: 'elections'; focus: 'all' | 'nld' | 'gouda' | 'usa'; data: views.ElectionsViewData }
| { kind: 'opinions'; data: views.OpinionsViewData }
| { kind: 'worldOpinions'; data: views.WorldOpinionsViewData }
| { kind: 'history'; data: views.HistoryViewData }
| { kind: 'heritage'; data: Awaited<ReturnType<typeof views.loadHeritageView>> };
export function loadView(v: ViewSpec): Promise<LoadedView> {
switch (v.kind) {
case 'economy':
return views.loadEconomyView().then((data) => ({ kind: v.kind, data }));
case 'energy':
return views.loadEnergyView().then((data) => ({ kind: v.kind, data }));
case 'gouda':
return views.loadGoudaView().then((data) => ({ kind: v.kind, data }));
case 'countries':
return views.loadCountriesView().then((data) => ({ kind: v.kind, data }));
case 'country':
return views.loadCountryView(v.code as CountryCode).then((data) => ({ kind: v.kind, data }));
case 'elections':
return views.loadElectionsView().then((data) => ({ kind: v.kind, focus: v.focus, data }));
case 'opinions':
return views.loadOpinionsView().then((data) => ({ kind: v.kind, data }));
case 'worldOpinions':
return views.loadWorldOpinionsView().then((data) => ({ kind: v.kind, data }));
case 'history':
return views.loadHistoryView().then((data) => ({ kind: v.kind, data }));
case 'heritage':
return views
.loadHeritageView(v.code as HeritageCode)
.then((data) => ({ kind: v.kind, data }));
}
}

Some files were not shown because too many files have changed in this diff Show More