Compare commits
26 Commits
money-dail
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ef75f9d25 | |||
| b566a1244f | |||
| f2f7bd0a19 | |||
| 7ba26f132d | |||
| f954881bcc | |||
| 95a8b76562 | |||
| 03574a95a7 | |||
| e24ee7f95d | |||
| fda21554cc | |||
| d7e26a3338 | |||
| 44379b9a27 | |||
| f7a3bb556c | |||
| 16684d6688 | |||
| d99245e9fc | |||
| 23a43049a0 | |||
| 3b75fd39b5 | |||
| 76f063b362 | |||
| 7396d23335 | |||
| 22279d181f | |||
| 8d2da984e0 | |||
| 9e89a615c6 | |||
| 9d79dfb43a | |||
| 21b10ea894 | |||
| 78b69c7d8f | |||
| d3b9bb9ba2 | |||
| 0121071af2 |
@@ -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
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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`
|
||||
|
||||
11
deploy/stats-collector/stats-jobs.timer
Normal file
11
deploy/stats-collector/stats-jobs.timer
Normal 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
|
||||
6
deploy/systemd/radio-iss.service
Normal file
6
deploy/systemd/radio-iss.service
Normal 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"
|
||||
10
deploy/systemd/radio-iss.timer
Normal file
10
deploy/systemd/radio-iss.timer
Normal 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
|
||||
6
deploy/systemd/radio-remind.service
Normal file
6
deploy/systemd/radio-remind.service
Normal 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"
|
||||
9
deploy/systemd/radio-remind.timer
Normal file
9
deploy/systemd/radio-remind.timer
Normal 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
81
docs/plant-tracker.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Plant tracker (`/trackers/cargill`)
|
||||
|
||||
A private (owner-only) knowledge base about the Cargill Bioindustrial plant in Gouda:
|
||||
departments, products, process steps, equipment, glossary terms, questions, notes and roles
|
||||
(never colleague names). Logged-out visitors get a 404 on the page, its data and its actions
|
||||
(`ownerOnlyPrefixes` in `src/lib/server/owner.ts`).
|
||||
|
||||
## Schema `plant` (migration `drizzle/20261026133106_plant`)
|
||||
|
||||
- `plant.entries`: `kind`, `slug` (unique per kind), `title`, `summary`, `body` (markdown),
|
||||
`parent_id` (any kind, e.g. a note under a note), `tags`, `sort_order`, `source`,
|
||||
`confidence` (`confirmed` | `assumed` | `open`), `answered_at` (questions), timestamps.
|
||||
- `plant.links`: `from_id`, `to_id`, `relation` (`feeds_into`, `made_in`, `uses`, `part_of`,
|
||||
`related`), optional `label`, `dashed`, `confidence` (default `confirmed`; `assumed` or
|
||||
`open` links show dashed in the flow and with a "?" badge on entry pages).
|
||||
- `plant.pending`: parents and links whose target doesn't exist yet. Every `apply_entries` run
|
||||
retries them, so a later batch that adds the target resolves them. Entry pages list them as
|
||||
"pending".
|
||||
|
||||
Kinds: `department`, `product`, `process_step`, `equipment`, `glossary`, `question`, `note`,
|
||||
`person_role`.
|
||||
|
||||
How tags drive the UI:
|
||||
|
||||
- `cluster` (departments): a section in the departments tab and a card in the overview, with
|
||||
every unit below it (units with `parent: "cluster-ea"`, and their own units, like MP1's).
|
||||
- `inactive`: dimmed everywhere; the "hide inactive" switch hides them (off by default).
|
||||
- `overview` (notes): shown at the top of the overview.
|
||||
|
||||
## Bulk import: `plant.apply_entries(jsonb)`
|
||||
|
||||
Upserts by `(kind, slug)`, then sets parents and links by slug. Run it as the app user or the
|
||||
owner; for big files pipe SQL through stdin, e.g.
|
||||
`select plant.apply_entries($doc$ { ... } $doc$::jsonb);`
|
||||
|
||||
The argument is either an object `{ "source": ..., "entries": [...], "links": [...],
|
||||
"delete": [...] }` or a bare array of entries.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "Walter White, batch 5 (2026-10-26)",
|
||||
"entries": [
|
||||
{
|
||||
"kind": "department",
|
||||
"slug": "414-kolom-1",
|
||||
"title": "414 Kolom 1",
|
||||
"summary": "Fat splitting column, continuous",
|
||||
"body": "In and out:\n- In: feed, water and high-pressure steam.\n- Out: fatty acid and glycerine water.",
|
||||
"parent": "cluster-dim",
|
||||
"tags": ["splitting", "continuous"],
|
||||
"confidence": "confirmed",
|
||||
"links": [{ "to": "splitting", "relation": "uses", "label": "optional" }]
|
||||
},
|
||||
{ "kind": "question", "slug": "rndm", "title": "What is RNDM?", "tags": ["answered"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Required: `kind`, plus `slug` or a `title` to slugify; new entries need a `title`.
|
||||
- Optional: `summary`, `body`, `parent` (alias `parent_slug`; `null` clears it), `tags`,
|
||||
`confidence` (default `assumed`, `open` for questions), `sort_order`, `source`, `answered`,
|
||||
`replace_links`, `replace_tags`, `body_mode` (`"append"`), `links` (`to`, `relation`,
|
||||
`label`, `dashed`, `confidence`, `remove`).
|
||||
- Updating an existing entry:
|
||||
- Keys that are left out keep their current value. An empty `summary` or `body` also keeps
|
||||
the current text. With `"body_mode": "append"` the new body is added below the current
|
||||
one (skipped when it is already there) instead of replacing it.
|
||||
- `tags` are merged into the existing tags, unless the entry sets
|
||||
`"confidence": "confirmed"` or `"replace_tags": true`, which replace them.
|
||||
- The top-level `source` is only the default for new entries; pass `source` on an entry to
|
||||
overwrite it.
|
||||
- A question tagged `answered` (or with `"answered": true`) is marked answered; the tag is
|
||||
dropped.
|
||||
- References (`parent`, `to`, `from`) are a slug or `kind:slug`. A bare slug used by several
|
||||
kinds resolves in this order: process_step, product, department, equipment, note,
|
||||
question, person_role, glossary (so `splitting` is the process step).
|
||||
- Unknown references never abort the import: they are listed in `unresolved` and kept in
|
||||
`plant.pending` until their target exists.
|
||||
|
||||
Returns `{ inserted, updated, linked, unlinked, deleted, unresolved: [{from, field, to}],
|
||||
resolved_pending, pending, entries: [{id, kind, slug, action}] }`.
|
||||
33
docs/radio-tracker.md
Normal file
33
docs/radio-tracker.md
Normal 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.
|
||||
@@ -35,7 +35,10 @@ export default defineConfig({
|
||||
'ai',
|
||||
'movies',
|
||||
'worldtodo',
|
||||
'notes'
|
||||
'notes',
|
||||
'math',
|
||||
'plant',
|
||||
'radio'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal file
32
drizzle/20261026082438_worldtodo_map/migration.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
CREATE TYPE "worldtodo"."location_precision" AS ENUM('exact', 'approximate', 'none');--> statement-breakpoint
|
||||
ALTER TABLE "worldtodo"."tasks" ADD COLUMN "location_precision" "worldtodo"."location_precision";--> statement-breakpoint
|
||||
-- Hand-written: location_precision follows scale and lat/lon (map pins, tracker #4641).
|
||||
-- street/city = exact, region/country = approximate (rough centre), planet or no coordinates
|
||||
-- = none. A trigger instead of a generated column so drizzle-kit can diff the enum column.
|
||||
CREATE OR REPLACE FUNCTION worldtodo.set_location_precision() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.location_precision := CASE
|
||||
WHEN NEW.lat IS NULL OR NEW.lon IS NULL OR NEW.scale = 'planet' THEN 'none'
|
||||
WHEN NEW.scale IN ('street', 'city') THEN 'exact'
|
||||
ELSE 'approximate'
|
||||
END::worldtodo.location_precision;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS tasks_location_precision ON worldtodo.tasks;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER tasks_location_precision BEFORE INSERT OR UPDATE OF scale, lat, lon, location_precision
|
||||
ON worldtodo.tasks FOR EACH ROW EXECUTE FUNCTION worldtodo.set_location_precision();
|
||||
--> statement-breakpoint
|
||||
-- Backfill (the trigger computes it) without bumping updated_at: touch_updated_at is paused
|
||||
-- for this one statement when it exists.
|
||||
DO $$
|
||||
DECLARE has_touch boolean := EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgrelid = 'worldtodo.tasks'::regclass AND tgname = 'touch_updated_at'
|
||||
);
|
||||
BEGIN
|
||||
IF has_touch THEN ALTER TABLE worldtodo.tasks DISABLE TRIGGER touch_updated_at; END IF;
|
||||
UPDATE worldtodo.tasks SET location_precision = NULL;
|
||||
IF has_touch THEN ALTER TABLE worldtodo.tasks ENABLE TRIGGER touch_updated_at; END IF;
|
||||
END $$;
|
||||
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
41238
drizzle/20261026082438_worldtodo_map/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal file
101
drizzle/20261026084751_cs_catalog/migration.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
CREATE TABLE "cs"."daily_prices" (
|
||||
"item_id" integer,
|
||||
"stream_id" smallint,
|
||||
"day" date,
|
||||
"price_cents" integer NOT NULL,
|
||||
"volume" integer,
|
||||
CONSTRAINT "daily_prices_pkey" PRIMARY KEY("item_id","stream_id","day")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."fx_rates" (
|
||||
"day" date PRIMARY KEY,
|
||||
"usd_eur" numeric(12,8) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."price_windows" (
|
||||
"item_id" integer,
|
||||
"source" text,
|
||||
"currency" text NOT NULL,
|
||||
"span" text,
|
||||
"median_cents" integer,
|
||||
"avg_cents" integer,
|
||||
"min_cents" integer,
|
||||
"max_cents" integer,
|
||||
"volume" integer,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "price_windows_pkey" PRIMARY KEY("item_id","source","span")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cs"."skins" (
|
||||
"id" text PRIMARY KEY,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"weapon" text,
|
||||
"pattern" text,
|
||||
"category" text,
|
||||
"rarity" text,
|
||||
"rarity_color" text,
|
||||
"image" text,
|
||||
"min_float" real,
|
||||
"max_float" real,
|
||||
"collections" text[],
|
||||
"source" text DEFAULT 'bymykel' NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "cs"."items_hash_idx";--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "skin_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "phase" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "wear" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "stattrak" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD COLUMN "souvenir" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "id" smallint GENERATED BY DEFAULT AS IDENTITY (sequence name "cs"."price_streams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 32767 START WITH 1 CACHE 1);--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "label" text;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_fetched_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_rows" integer;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_error" text;--> statement-breakpoint
|
||||
CREATE INDEX "daily_prices_day_idx" ON "cs"."daily_prices" ("day","stream_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "items_config_idx" ON "cs"."items" ("market_hash_name",coalesce("phase", ''));--> statement-breakpoint
|
||||
CREATE INDEX "items_skin_idx" ON "cs"."items" ("skin_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "price_streams_id_idx" ON "cs"."price_streams" ("id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "skins_slug_idx" ON "cs"."skins" ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "skins_name_idx" ON "cs"."skins" ("name");--> statement-breakpoint
|
||||
CREATE INDEX "skins_kind_idx" ON "cs"."skins" ("kind");--> statement-breakpoint
|
||||
ALTER TABLE "cs"."daily_prices" ADD CONSTRAINT "daily_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."items" ADD CONSTRAINT "items_skin_id_skins_id_fkey" FOREIGN KEY ("skin_id") REFERENCES "cs"."skins"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cs"."price_windows" ADD CONSTRAINT "price_windows_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
-- Facets for the listings that already exist (the daily job keeps these up to date).
|
||||
UPDATE cs.items SET
|
||||
stattrak = market_hash_name LIKE '%StatTrak™ %',
|
||||
souvenir = market_hash_name LIKE 'Souvenir %',
|
||||
wear = substring(market_hash_name from '\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$');
|
||||
--> statement-breakpoint
|
||||
-- Price streams the daily job writes (one row per config per stream per day).
|
||||
INSERT INTO cs.price_streams (source, currency, kind, label) VALUES
|
||||
('skinport', 'EUR', 'lowest-listing', 'Skinport lowest listing'),
|
||||
('skinport', 'EUR', 'sale-median-24h', 'Skinport sales, 24h median'),
|
||||
('steam-market', 'USD', 'sale-avg', 'Steam Market average sale (24h, else 7d)'),
|
||||
('csfloat', 'USD', 'lowest-listing', 'CSFloat lowest listing'),
|
||||
('buff163', 'USD', 'lowest-listing', 'Buff163 lowest listing')
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label;
|
||||
--> statement-breakpoint
|
||||
UPDATE cs.price_streams SET label = 'Steam Market lowest listing'
|
||||
WHERE source = 'steam-market' AND currency = 'USD' AND kind = 'lowest-listing' AND label IS NULL;
|
||||
--> statement-breakpoint
|
||||
-- Backfill: the one Steam lowest-listing sweep from Sep 2026 becomes the first daily point.
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT DISTINCT ON (p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date)
|
||||
p.item_id, s.id, (p.observed_at AT TIME ZONE 'UTC')::date, p.price_cents, p.volume
|
||||
FROM cs.price_observations p
|
||||
JOIN cs.price_streams s ON s.source = p.source AND s.currency = p.currency AND s.kind = p.kind
|
||||
ORDER BY p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date, p.observed_at DESC
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON cs.skins, cs.daily_prices, cs.price_windows, cs.fx_rates TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cs TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
42052
drizzle/20261026084751_cs_catalog/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "ai"."provider_setup_entries" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"provider_id" integer NOT NULL,
|
||||
"section" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"note" text,
|
||||
"scope" text,
|
||||
"link" text,
|
||||
"body" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_entries_section_check" CHECK ("section" IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')),
|
||||
CONSTRAINT "provider_setup_entries_scope_check" CHECK ("scope" IS NULL OR "scope" IN ('global','per-repo','app')),
|
||||
CONSTRAINT "provider_setup_entries_name_check" CHECK (length(btrim("name")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai"."provider_setup_providers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"products" text,
|
||||
"color" text DEFAULT 'zinc' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "provider_setup_providers_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "provider_setup_providers_color_check" CHECK ("color" IN ('orange','green','blue','violet','pink','sky','amber','zinc'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "provider_setup_entries_provider_idx" ON "ai"."provider_setup_entries" ("provider_id","section","sort_order");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "provider_setup_providers_name_key" ON "ai"."provider_setup_providers" ("name");--> statement-breakpoint
|
||||
ALTER TABLE "ai"."provider_setup_entries" ADD CONSTRAINT "provider_setup_entries_JxI1C1Zk458j_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai"."provider_setup_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
--> statement-breakpoint
|
||||
-- Seed the provider cards (owner edits them on /trackers/ai/setup). Entries are added there.
|
||||
INSERT INTO "ai"."provider_setup_providers" ("name", "products", "color", "sort_order") VALUES
|
||||
('Claude', 'claude.ai + Claude Code', 'orange', 10),
|
||||
('ChatGPT', 'ChatGPT + Codex', 'green', 20),
|
||||
('Gemini', 'Gemini + Gemini CLI', 'blue', 30),
|
||||
('Grok', 'Grok + Grok Bot', 'violet', 40),
|
||||
('Copilot', 'GitHub Copilot', 'sky', 50)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
-- Same privacy as the rest of schema ai: app role only (default privileges already cover new
|
||||
-- tables; granted explicitly too). Grants only run on prod (migrations as `personal`).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON "ai"."provider_setup_providers", "ai"."provider_setup_entries" TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "cs"."history_fetches" (
|
||||
"item_id" integer,
|
||||
"source" text,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"points" integer DEFAULT 0 NOT NULL,
|
||||
"first_day" date,
|
||||
"error" text,
|
||||
CONSTRAINT "history_fetches_pkey" PRIMARY KEY("item_id","source")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cs"."history_fetches" ADD CONSTRAINT "history_fetches_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;
|
||||
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
983
drizzle/20261026104832_math/migration.sql
Normal file
983
drizzle/20261026104832_math/migration.sql
Normal file
@@ -0,0 +1,983 @@
|
||||
CREATE SCHEMA "math";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "math"."activity" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"chapter_id" integer NOT NULL,
|
||||
"section_id" integer,
|
||||
"kind" text NOT NULL,
|
||||
"from_value" text,
|
||||
"to_value" text,
|
||||
"delta" integer,
|
||||
"source" text DEFAULT 'sql' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('status','exercises','note','section'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "math"."chapters" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"key" text NOT NULL,
|
||||
"part_id" integer NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"page_start" integer NOT NULL,
|
||||
"page_end" integer NOT NULL,
|
||||
"exercise_count" integer,
|
||||
"prereqs" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"status" text DEFAULT 'not_started' NOT NULL,
|
||||
"exercises_done" integer DEFAULT 0 NOT NULL,
|
||||
"notes" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"sort_order" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chapters_status_check" CHECK ("status" IN ('not_started','reading','exercises','done','skipped')),
|
||||
CONSTRAINT "chapters_exercises_done_check" CHECK ("exercises_done" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "math"."parts" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"key" text NOT NULL,
|
||||
"num" integer,
|
||||
"title" text NOT NULL,
|
||||
"sort_order" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "math"."sections" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"chapter_id" integer NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"sort_order" integer NOT NULL,
|
||||
"done_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "activity_created_idx" ON "math"."activity" ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "activity_chapter_idx" ON "math"."activity" ("chapter_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "chapters_key_key" ON "math"."chapters" ("key");--> statement-breakpoint
|
||||
CREATE INDEX "chapters_part_idx" ON "math"."chapters" ("part_id","sort_order");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "parts_key_key" ON "math"."parts" ("key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sections_chapter_key_key" ON "math"."sections" ("chapter_id","key");--> statement-breakpoint
|
||||
ALTER TABLE "math"."activity" ADD CONSTRAINT "activity_chapter_id_chapters_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "math"."chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "math"."activity" ADD CONSTRAINT "activity_section_id_sections_id_fkey" FOREIGN KEY ("section_id") REFERENCES "math"."sections"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "math"."chapters" ADD CONSTRAINT "chapters_part_id_parts_id_fkey" FOREIGN KEY ("part_id") REFERENCES "math"."parts"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "math"."sections" ADD CONSTRAINT "sections_chapter_id_chapters_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "math"."chapters"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
||||
-- Seed: the Napkin outline (parts, chapters with PDF page ranges, exercise counts and approximate
|
||||
-- prereqs, sections) from Terence Tao's extraction of the book (2026-10-26). Progress starts empty.
|
||||
INSERT INTO "math"."parts" ("key", "num", "title", "sort_order") VALUES
|
||||
('1', 1, 'Starting Out', 1),
|
||||
('2', 2, 'Basic Abstract Algebra', 2),
|
||||
('3', 3, 'Basic Topology', 3),
|
||||
('4', 4, 'Linear Algebra', 4),
|
||||
('5', 5, 'More on Groups', 5),
|
||||
('6', 6, 'Representation Theory', 6),
|
||||
('7', 7, 'Quantum Algorithms', 7),
|
||||
('8', 8, 'Calculus 101', 8),
|
||||
('9', 9, 'Complex Analysis', 9),
|
||||
('10', 10, 'Measure Theory', 10),
|
||||
('11', 11, 'Probability (TO DO)', 11),
|
||||
('12', 12, 'Differential Geometry', 12),
|
||||
('13', 13, 'Riemann Surfaces', 13),
|
||||
('14', 14, 'Algebraic NT I: Rings of Integers', 14),
|
||||
('15', 15, 'Algebraic NT II: Galois and Ramification Theory', 15),
|
||||
('16', 16, 'Algebraic Topology I: Homotopy', 16),
|
||||
('17', 17, 'Category Theory', 17),
|
||||
('18', 18, 'Algebraic Topology II: Homology', 18),
|
||||
('19', 19, 'Algebraic Geometry I: Classical Varieties', 19),
|
||||
('20', 20, 'Algebraic Geometry II: Affine Schemes', 20),
|
||||
('21', 21, 'Set Theory I: ZFC, Ordinals, and Cardinals', 21),
|
||||
('22', 22, 'Set Theory II: Model Theory and Forcing', 22),
|
||||
('appendix', NULL, 'Appendix', 23);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "math"."chapters" ("key", "part_id", "title", "page_start", "page_end", "exercise_count", "prereqs", "sort_order") VALUES
|
||||
('0', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Sales pitches', 37, 42, NULL, '{}'::text[], 1),
|
||||
('1', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Groups', 43, 54, 9, '{}'::text[], 2),
|
||||
('2', (SELECT id FROM "math"."parts" WHERE key = '1'), 'Metric spaces', 55, 68, 6, '{}'::text[], 3),
|
||||
('3', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Homomorphisms and quotient groups', 69, 80, 8, '{}'::text[], 4),
|
||||
('4', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Rings and ideals', 81, 92, 8, '{}'::text[], 5),
|
||||
('5', (SELECT id FROM "math"."parts" WHERE key = '2'), 'Flavors of rings', 93, 108, 9, '{}'::text[], 6),
|
||||
('6', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Properties of metric spaces', 109, 114, 5, '{}'::text[], 7),
|
||||
('7', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Topological spaces', 115, 124, 8, '{}'::text[], 8),
|
||||
('8', (SELECT id FROM "math"."parts" WHERE key = '3'), 'Compactness', 125, 138, 9, '{}'::text[], 9),
|
||||
('9', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Vector spaces', 139, 156, 9, '{1,3,4,5}'::text[], 10),
|
||||
('10', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Eigen-things', 157, 166, 7, '{1,3,4,5}'::text[], 11),
|
||||
('11', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Dual space and trace', 167, 174, 4, '{1,3,4,5}'::text[], 12),
|
||||
('12', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Determinant', 175, 182, 8, '{1,3,4,5}'::text[], 13),
|
||||
('13', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Inner product spaces', 183, 190, 8, '{1,3,4,5}'::text[], 14),
|
||||
('14', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Bonus: Fourier analysis', 191, 200, 2, '{1,3,4,5}'::text[], 15),
|
||||
('15', (SELECT id FROM "math"."parts" WHERE key = '4'), 'Duals, adjoint, and transposes', 201, 212, 8, '{1,3,4,5}'::text[], 16),
|
||||
('16', (SELECT id FROM "math"."parts" WHERE key = '5'), 'Group actions overkill AIME problems', 213, 218, 5, '{1,3,4,5}'::text[], 17),
|
||||
('17', (SELECT id FROM "math"."parts" WHERE key = '5'), 'Find all groups', 219, 224, 5, '{16}'::text[], 18),
|
||||
('18', (SELECT id FROM "math"."parts" WHERE key = '5'), 'The PID structure theorem', 225, 236, 5, '{1,3,4,5}'::text[], 19),
|
||||
('19', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Representations of algebras', 237, 248, 5, '{9,10,11,12,13,14,15,18}'::text[], 20),
|
||||
('20', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Semisimple algebras', 249, 256, 5, '{9,10,11,12,13,14,15,18}'::text[], 21),
|
||||
('21', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Characters', 257, 264, 5, '{9,10,11,12,13,14,15,18}'::text[], 22),
|
||||
('22', (SELECT id FROM "math"."parts" WHERE key = '6'), 'Some applications', 265, 270, NULL, '{9,10,11,12,13,14,15,18}'::text[], 23),
|
||||
('23', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Quantum states and measurements', 271, 278, 2, '{9,10,11,12,13,14,15,18}'::text[], 24),
|
||||
('24', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Quantum circuits', 279, 286, 4, '{9,10,11,12,13,14,15,18}'::text[], 25),
|
||||
('25', (SELECT id FROM "math"."parts" WHERE key = '7'), 'Shor''s algorithm', 287, 294, NULL, '{9,10,11,12,13,14,15,18}'::text[], 26),
|
||||
('26', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Limits and series', 295, 306, 8, '{2,6,7,8}'::text[], 27),
|
||||
('27', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Bonus: A hint of p-adic numbers', 307, 318, 4, '{2,6,7,8}'::text[], 28),
|
||||
('28', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Differentiation', 319, 330, 7, '{2,6,7,8}'::text[], 29),
|
||||
('29', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Power series and Taylor series', 331, 338, 5, '{2,6,7,8}'::text[], 30),
|
||||
('30', (SELECT id FROM "math"."parts" WHERE key = '8'), 'Riemann integrals', 339, 348, 4, '{2,6,7,8}'::text[], 31),
|
||||
('31', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Holomorphic functions', 349, 364, 6, '{26,27,28,29,30}'::text[], 32),
|
||||
('32', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Meromorphic functions', 365, 374, 5, '{26,27,28,29,30}'::text[], 33),
|
||||
('33', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Holomorphic square roots and logarithms', 375, 380, 2, '{26,27,28,29,30}'::text[], 34),
|
||||
('34', (SELECT id FROM "math"."parts" WHERE key = '9'), 'Bonus: Topological Abel-Ruffini Theorem', 381, 386, 1, '{26,27,28,29,30}'::text[], 35),
|
||||
('35', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Measure spaces', 387, 394, 2, '{26,27,28,29,30}'::text[], 36),
|
||||
('36', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Constructing the Borel and Lebesgue measure', 395, 404, 3, '{26,27,28,29,30}'::text[], 37),
|
||||
('37', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Lebesgue integration', 405, 410, 2, '{26,27,28,29,30}'::text[], 38),
|
||||
('38', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Swapping order with Lebesgue integrals', 411, 418, NULL, '{26,27,28,29,30}'::text[], 39),
|
||||
('39', (SELECT id FROM "math"."parts" WHERE key = '10'), 'Bonus: A hint of Pontryagin duality', 419, 426, 2, '{26,27,28,29,30}'::text[], 40),
|
||||
('40', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Random variables (TO DO)', 427, 428, 2, '{35,36,37,38,39}'::text[], 41),
|
||||
('41', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Large number laws (TO DO)', 429, 436, 2, '{35,36,37,38,39}'::text[], 42),
|
||||
('42', (SELECT id FROM "math"."parts" WHERE key = '11'), 'Stopped martingales (TO DO)', 437, 452, 6, '{35,36,37,38,39}'::text[], 43),
|
||||
('43', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Multivariable calculus done correctly', 453, 460, 2, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 44),
|
||||
('44', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Differential forms', 461, 472, 2, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 45),
|
||||
('45', (SELECT id FROM "math"."parts" WHERE key = '12'), 'Integrating differential forms', 473, 484, 7, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 46),
|
||||
('46', (SELECT id FROM "math"."parts" WHERE key = '12'), 'A bit of manifolds', 485, 496, 1, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 47),
|
||||
('47', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Basic definitions of Riemann surfaces', 497, 504, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 48),
|
||||
('48', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Morphisms between Riemann surfaces', 505, 510, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 49),
|
||||
('49', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Affine and projective plane curves', 511, 520, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 50),
|
||||
('50', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Differential forms', 521, 524, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 51),
|
||||
('51', (SELECT id FROM "math"."parts" WHERE key = '13'), 'The Riemann-Roch theorem', 525, 530, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 52),
|
||||
('52', (SELECT id FROM "math"."parts" WHERE key = '13'), 'Line bundles', 531, 540, NULL, '{9,10,11,12,13,14,15,18,26,27,28,29,30}'::text[], 53),
|
||||
('53', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Algebraic integers', 541, 546, 9, '{9,10,11,12,13,14,15,18}'::text[], 54),
|
||||
('54', (SELECT id FROM "math"."parts" WHERE key = '14'), 'The ring of integers', 547, 556, 6, '{9,10,11,12,13,14,15,18}'::text[], 55),
|
||||
('55', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Unique factorization (finally!)', 557, 568, 5, '{9,10,11,12,13,14,15,18}'::text[], 56),
|
||||
('56', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Minkowski bound and class groups', 569, 588, 6, '{9,10,11,12,13,14,15,18}'::text[], 57),
|
||||
('57', (SELECT id FROM "math"."parts" WHERE key = '14'), 'More properties of the discriminant', 589, 590, 6, '{9,10,11,12,13,14,15,18}'::text[], 58),
|
||||
('58', (SELECT id FROM "math"."parts" WHERE key = '14'), 'Bonus: Let''s solve Pell''s equation!', 591, 598, 2, '{9,10,11,12,13,14,15,18}'::text[], 59),
|
||||
('59', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Things Galois', 599, 610, 6, '{9,10,11,12,13,14,15,18}'::text[], 60),
|
||||
('60', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Finite fields', 611, 616, 1, '{53,54,55,56,57,58,59}'::text[], 61),
|
||||
('61', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Ramification theory', 617, 626, 1, '{53,54,55,56,57,58,59}'::text[], 62),
|
||||
('62', (SELECT id FROM "math"."parts" WHERE key = '15'), 'The Frobenius element', 627, 638, 5, '{53,54,55,56,57,58,59}'::text[], 63),
|
||||
('63', (SELECT id FROM "math"."parts" WHERE key = '15'), 'Bonus: A Bit on Artin Reciprocity', 639, 654, 3, '{53,54,55,56,57,58,59}'::text[], 64),
|
||||
('64', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Some topological constructions', 655, 666, 3, '{1,2,3,4,5,6,7,8}'::text[], 65),
|
||||
('65', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Fundamental groups', 667, 678, 4, '{1,2,3,4,5,6,7,8}'::text[], 66),
|
||||
('66', (SELECT id FROM "math"."parts" WHERE key = '16'), 'Covering projections', 679, 692, NULL, '{1,2,3,4,5,6,7,8}'::text[], 67),
|
||||
('67', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Objects and morphisms', 693, 704, 4, '{1,3,4,5}'::text[], 68),
|
||||
('68', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Functors and natural transformations', 705, 716, 3, '{1,3,4,5}'::text[], 69),
|
||||
('69', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Limits in categories (TO DO)', 717, 718, 1, '{1,3,4,5}'::text[], 70),
|
||||
('70', (SELECT id FROM "math"."parts" WHERE key = '17'), 'Abelian categories', 719, 728, 4, '{1,3,4,5}'::text[], 71),
|
||||
('71', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Singular homology', 729, 742, 2, '{64,65,66,67,68,69,70}'::text[], 72),
|
||||
('72', (SELECT id FROM "math"."parts" WHERE key = '18'), 'The long exact sequence', 743, 754, 6, '{64,65,66,67,68,69,70}'::text[], 73),
|
||||
('73', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Excision and relative homology', 755, 762, 5, '{64,65,66,67,68,69,70}'::text[], 74),
|
||||
('74', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Bonus: Cellular homology', 763, 774, 5, '{64,65,66,67,68,69,70}'::text[], 75),
|
||||
('75', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Singular cohomology', 775, 786, 3, '{64,65,66,67,68,69,70}'::text[], 76),
|
||||
('76', (SELECT id FROM "math"."parts" WHERE key = '18'), 'Application of cohomology', 787, 806, 4, '{64,65,66,67,68,69,70}'::text[], 77),
|
||||
('77', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Affine varieties', 807, 816, 3, '{1,3,4,5}'::text[], 78),
|
||||
('78', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Affine varieties as ringed spaces', 817, 826, 3, '{1,3,4,5}'::text[], 79),
|
||||
('79', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Projective varieties', 827, 834, NULL, '{1,3,4,5}'::text[], 80),
|
||||
('80', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Bonus: Bézout''s theorem', 835, 842, 2, '{1,3,4,5}'::text[], 81),
|
||||
('81', (SELECT id FROM "math"."parts" WHERE key = '19'), 'Morphisms of varieties', 843, 854, 4, '{1,3,4,5}'::text[], 82),
|
||||
('82', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Sheaves and ringed spaces', 855, 866, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 83),
|
||||
('83', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Localization', 867, 876, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 84),
|
||||
('84', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Affine schemes: the Zariski topology', 877, 886, 3, '{67,68,69,70,77,78,79,80,81}'::text[], 85),
|
||||
('85', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Affine schemes: the sheaf', 887, 896, 5, '{67,68,69,70,77,78,79,80,81}'::text[], 86),
|
||||
('86', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Interlude: eighteen examples of affine schemes', 897, 912, 4, '{67,68,69,70,77,78,79,80,81}'::text[], 87),
|
||||
('87', (SELECT id FROM "math"."parts" WHERE key = '20'), 'Morphisms of locally ringed spaces', 913, 926, 2, '{67,68,69,70,77,78,79,80,81}'::text[], 88),
|
||||
('88', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Interlude: Cauchy''s functional equation and Zorn''s lemma', 927, 934, 3, '{}'::text[], 89),
|
||||
('89', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Zermelo-Fraenkel with choice', 935, 942, 5, '{}'::text[], 90),
|
||||
('90', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Ordinals', 943, 954, 3, '{}'::text[], 91),
|
||||
('91', (SELECT id FROM "math"."parts" WHERE key = '21'), 'Cardinals', 955, 964, 4, '{}'::text[], 92),
|
||||
('92', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Inner model theory', 965, 976, 4, '{}'::text[], 93),
|
||||
('93', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Forcing', 977, 986, 2, '{}'::text[], 94),
|
||||
('94', (SELECT id FROM "math"."parts" WHERE key = '22'), 'Breaking the continuum hypothesis', 987, 994, 1, '{}'::text[], 95),
|
||||
('A', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Pedagogical comments and references', 995, 998, NULL, '{}'::text[], 96),
|
||||
('B', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Hints to selected problems', 999, 1010, NULL, '{}'::text[], 97),
|
||||
('C', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Sketches of selected solutions', 1011, 1038, NULL, '{}'::text[], 98),
|
||||
('D', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Glossary of notations', 1039, 1048, NULL, '{}'::text[], 99),
|
||||
('E', (SELECT id FROM "math"."parts" WHERE key = 'appendix'), 'Terminology on sets and functions', 1049, 1052, NULL, '{}'::text[], 100);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "math"."sections" ("chapter_id", "key", "title", "sort_order")
|
||||
SELECT c.id, v.key, v.title, v.ord FROM (VALUES
|
||||
('0', '0.1', 'The basics', 1),
|
||||
('0', '0.2', 'Abstract algebra', 2),
|
||||
('0', '0.3', 'Real and complex analysis', 3),
|
||||
('0', '0.4', 'Algebraic number theory', 4),
|
||||
('0', '0.5', 'Algebraic topology', 5),
|
||||
('0', '0.6', 'Algebraic geometry', 6),
|
||||
('0', '0.7', 'Set theory', 7),
|
||||
('1', '1.1', 'Definition and examples of groups', 1),
|
||||
('1', '1.2', 'Properties of groups', 2),
|
||||
('1', '1.3', 'Isomorphisms', 3),
|
||||
('1', '1.4', 'Orders of groups, and Lagrange''s theorem', 4),
|
||||
('1', '1.5', 'Subgroups', 5),
|
||||
('1', '1.6', 'Groups of small orders', 6),
|
||||
('1', '1.7', 'Unimportant long digression', 7),
|
||||
('1', '1.8', 'A few harder problems to think about', 8),
|
||||
('2', '2.1', 'Definition and examples of metric spaces', 1),
|
||||
('2', '2.2', 'Convergence in metric spaces', 2),
|
||||
('2', '2.3', 'Continuous maps', 3),
|
||||
('2', '2.4', 'Homeomorphisms', 4),
|
||||
('2', '2.5', 'Extended example/definition: product metric', 5),
|
||||
('2', '2.6', 'Open sets', 6),
|
||||
('2', '2.7', 'Closed sets', 7),
|
||||
('2', '2.8', 'A few harder problems to think about', 8),
|
||||
('3', '3.1', 'Generators and group presentations', 1),
|
||||
('3', '3.2', 'Homomorphisms', 2),
|
||||
('3', '3.3', 'Cosets and modding out', 3),
|
||||
('3', '3.4', '(Optional) Proof of Lagrange''s theorem', 4),
|
||||
('3', '3.5', 'Eliminating the homomorphism', 5),
|
||||
('3', '3.6', '(Digression) The first isomorphism theorem', 6),
|
||||
('3', '3.7', 'A few harder problems to think about', 7),
|
||||
('4', '4.1', 'Some motivational metaphors about rings vs groups', 1),
|
||||
('4', '4.2', '(Optional) Pedagogical notes on motivation', 2),
|
||||
('4', '4.3', 'Definition and examples of rings', 3),
|
||||
('4', '4.4', 'Fields', 4),
|
||||
('4', '4.5', 'Homomorphisms', 5),
|
||||
('4', '4.6', 'Ideals', 6),
|
||||
('4', '4.7', 'Generating ideals', 7),
|
||||
('4', '4.8', 'Principal ideal domains', 8),
|
||||
('4', '4.9', 'Noetherian rings', 9),
|
||||
('4', '4.10', 'A few harder problems to think about', 10),
|
||||
('5', '5.1', 'Fields', 1),
|
||||
('5', '5.2', 'Integral domains', 2),
|
||||
('5', '5.3', 'Prime ideals', 3),
|
||||
('5', '5.4', 'Maximal ideals', 4),
|
||||
('5', '5.5', 'Field of fractions', 5),
|
||||
('5', '5.6', 'Unique factorization domains (UFD''s)', 6),
|
||||
('5', '5.7', 'Extra: Euclidean domains', 7),
|
||||
('5', '5.8', 'A few harder problems to think about', 8),
|
||||
('6', '6.1', 'Boundedness', 1),
|
||||
('6', '6.2', 'Completeness', 2),
|
||||
('6', '6.3', 'Let the buyer beware', 3),
|
||||
('6', '6.4', 'Subspaces, and (inb4) a confusing linguistic point', 4),
|
||||
('6', '6.5', 'A few harder problems to think about', 5),
|
||||
('7', '7.1', 'Forgetting the metric', 1),
|
||||
('7', '7.2', 'Re-definitions', 2),
|
||||
('7', '7.3', 'Hausdorff spaces', 3),
|
||||
('7', '7.4', 'Subspaces', 4),
|
||||
('7', '7.5', 'Connected spaces', 5),
|
||||
('7', '7.6', 'Path-connected spaces', 6),
|
||||
('7', '7.7', 'Homotopy and simply connected spaces', 7),
|
||||
('7', '7.8', 'Bases of spaces', 8),
|
||||
('7', '7.9', 'A few harder problems to think about', 9),
|
||||
('8', '8.1', 'Definition of sequential compactness', 1),
|
||||
('8', '8.2', 'Criteria for compactness', 2),
|
||||
('8', '8.3', 'Compactness using open covers', 3),
|
||||
('8', '8.4', 'Applications of compactness', 4),
|
||||
('8', '8.5', '(Optional) Equivalence of formulations of compactness', 5),
|
||||
('8', '8.6', 'A few harder problems to think about', 6),
|
||||
('9', '9.1', 'The definitions of a ring and field', 1),
|
||||
('9', '9.2', 'Modules and vector spaces', 2),
|
||||
('9', '9.3', 'Direct sums', 3),
|
||||
('9', '9.4', 'Linear independence, spans, and basis', 4),
|
||||
('9', '9.5', 'Linear maps', 5),
|
||||
('9', '9.6', 'What is a matrix?', 6),
|
||||
('9', '9.7', 'Subspaces and picking convenient bases', 7),
|
||||
('9', '9.8', 'A cute application: Lagrange interpolation', 8),
|
||||
('9', '9.9', 'Pedagogical digression: Arrays of numbers are evil', 9),
|
||||
('9', '9.10', 'A word on general modules', 10),
|
||||
('9', '9.11', 'A few harder problems to think about', 11),
|
||||
('10', '10.1', 'Why you should care', 1),
|
||||
('10', '10.2', 'Warning on assumptions', 2),
|
||||
('10', '10.3', 'Eigenvectors and eigenvalues', 3),
|
||||
('10', '10.4', 'The Jordan form', 4),
|
||||
('10', '10.5', 'Nilpotent maps', 5),
|
||||
('10', '10.6', 'Reducing to the nilpotent case', 6),
|
||||
('10', '10.7', '(Optional) Proof of nilpotent Jordan', 7),
|
||||
('10', '10.8', 'Algebraic and geometric multiplicity', 8),
|
||||
('10', '10.9', 'A few harder problems to think about', 9),
|
||||
('11', '11.1', 'Tensor product', 1),
|
||||
('11', '11.2', 'Dual space', 2),
|
||||
('11', '11.3', 'VW gives matrices from V to W', 3),
|
||||
('11', '11.4', 'The trace', 4),
|
||||
('11', '11.5', 'A few harder problems to think about', 5),
|
||||
('12', '12.1', 'Wedge product', 1),
|
||||
('12', '12.2', 'The determinant', 2),
|
||||
('12', '12.3', 'Characteristic polynomials, and Cayley-Hamilton', 3),
|
||||
('12', '12.4', 'A few harder problems to think about', 4),
|
||||
('13', '13.1', 'The inner product', 1),
|
||||
('13', '13.2', 'Norms', 2),
|
||||
('13', '13.3', 'Orthogonality', 3),
|
||||
('13', '13.4', 'Hilbert spaces', 4),
|
||||
('13', '13.5', 'A few harder problems to think about', 5),
|
||||
('14', '14.1', 'Synopsis', 1),
|
||||
('14', '14.2', 'A reminder on Hilbert spaces', 2),
|
||||
('14', '14.3', 'Common examples', 3),
|
||||
('14', '14.4', 'Summary, and another teaser', 4),
|
||||
('14', '14.5', 'Parseval and friends', 5),
|
||||
('14', '14.6', 'Application: Basel problem', 6),
|
||||
('14', '14.7', 'Application: Arrow''s Impossibility Theorem', 7),
|
||||
('14', '14.8', 'A few harder problems to think about', 8),
|
||||
('15', '15.1', 'Dual of a map', 1),
|
||||
('15', '15.2', '(Optional) A cautionary tale on what goes wrong if you don''t have a natural isomorphism V .5-.5.5-.5.5-.5.5-.5V', 2),
|
||||
('15', '15.3', 'Identifying with the dual space', 3),
|
||||
('15', '15.4', 'The adjoint (conjugate transpose)', 4),
|
||||
('15', '15.5', 'Eigenvalues of normal maps', 5),
|
||||
('15', '15.6', 'A few harder problems to think about', 6),
|
||||
('16', '16.1', 'Definition of a group action', 1),
|
||||
('16', '16.2', 'Stabilizers and orbits', 2),
|
||||
('16', '16.3', 'Burnside''s lemma', 3),
|
||||
('16', '16.4', 'Conjugation of elements', 4),
|
||||
('16', '16.5', 'A few harder problems to think about', 5),
|
||||
('17', '17.1', 'Sylow theorems', 1),
|
||||
('17', '17.2', '(Optional) Proving Sylow''s theorem', 2),
|
||||
('17', '17.3', '(Optional) Simple groups and Jordan-Hölder', 3),
|
||||
('17', '17.4', 'A few harder problems to think about', 4),
|
||||
('18', '18.1', 'Finitely generated abelian groups', 1),
|
||||
('18', '18.2', 'Some ring theory prerequisites', 2),
|
||||
('18', '18.3', 'The structure theorem', 3),
|
||||
('18', '18.4', 'Reduction to maps of free R-modules', 4),
|
||||
('18', '18.5', 'Uniqueness of primary form', 5),
|
||||
('18', '18.6', 'Smith normal form', 6),
|
||||
('18', '18.7', 'A few harder problems to think about', 7),
|
||||
('19', '19.1', 'Algebras', 1),
|
||||
('19', '19.2', 'Representations', 2),
|
||||
('19', '19.3', 'Direct sums', 3),
|
||||
('19', '19.4', 'Irreducible and indecomposable representations', 4),
|
||||
('19', '19.5', 'Morphisms of representations', 5),
|
||||
('19', '19.6', 'The representations of Matd(k)', 6),
|
||||
('19', '19.7', 'A few harder problems to think about', 7),
|
||||
('20', '20.1', 'Schur''s lemma continued', 1),
|
||||
('20', '20.2', 'Density theorem', 2),
|
||||
('20', '20.3', 'Semisimple algebras', 3),
|
||||
('20', '20.4', 'Maschke''s theorem', 4),
|
||||
('20', '20.5', 'Example: the representations of C[S3]', 5),
|
||||
('20', '20.6', 'A few harder problems to think about', 6),
|
||||
('21', '21.1', 'Definitions', 1),
|
||||
('21', '21.2', 'The dual space modulo the commutator', 2),
|
||||
('21', '21.3', 'Orthogonality of characters', 3),
|
||||
('21', '21.4', 'Examples of character tables', 4),
|
||||
('21', '21.5', 'A few harder problems to think about', 5),
|
||||
('22', '22.1', 'Frobenius divisibility', 1),
|
||||
('22', '22.2', 'Burnside''s theorem', 2),
|
||||
('22', '22.3', 'Frobenius determinant', 3),
|
||||
('23', '23.1', 'Bra-ket notation', 1),
|
||||
('23', '23.2', 'The state space', 2),
|
||||
('23', '23.3', 'Observations', 3),
|
||||
('23', '23.4', 'Entanglement', 4),
|
||||
('23', '23.5', 'A few harder problems to think about', 5),
|
||||
('24', '24.1', 'Classical logic gates', 1),
|
||||
('24', '24.2', 'Reversible classical logic', 2),
|
||||
('24', '24.3', 'Quantum logic gates', 3),
|
||||
('24', '24.4', 'Deutsch-Jozsa algorithm', 4),
|
||||
('24', '24.5', 'A few harder problems to think about', 5),
|
||||
('25', '25.1', 'The classical (inverse) Fourier transform', 1),
|
||||
('25', '25.2', 'The quantum Fourier transform', 2),
|
||||
('25', '25.3', 'Shor''s algorithm', 3),
|
||||
('26', '26.1', 'Completeness and inf/sup', 1),
|
||||
('26', '26.2', 'Proofs of the two key completeness properties of R', 2),
|
||||
('26', '26.3', 'Monotonic sequences', 3),
|
||||
('26', '26.4', 'Infinite series', 4),
|
||||
('26', '26.5', 'Series addition is not commutative: a horror story', 5),
|
||||
('26', '26.6', 'Limits of functions at points', 6),
|
||||
('26', '26.7', 'Limits of functions at infinity', 7),
|
||||
('26', '26.8', 'A few harder problems to think about', 8),
|
||||
('27', '27.1', 'Motivation', 1),
|
||||
('27', '27.2', 'Algebraic perspective', 2),
|
||||
('27', '27.3', 'Analytic perspective', 3),
|
||||
('27', '27.4', 'Mahler coefficients', 4),
|
||||
('27', '27.5', 'A few harder problems to think about', 5),
|
||||
('28', '28.1', 'Definition', 1),
|
||||
('28', '28.2', 'How to compute them', 2),
|
||||
('28', '28.3', 'Local (and global) maximums', 3),
|
||||
('28', '28.4', 'Rolle and friends', 4),
|
||||
('28', '28.5', 'Smooth functions', 5),
|
||||
('28', '28.6', 'A few harder problems to think about', 6),
|
||||
('29', '29.1', 'Motivation', 1),
|
||||
('29', '29.2', 'Power series', 2),
|
||||
('29', '29.3', 'Differentiating them', 3),
|
||||
('29', '29.4', 'Analytic functions', 4),
|
||||
('29', '29.5', 'A definition of Euler''s constant and exponentiation', 5),
|
||||
('29', '29.6', 'This all works over complex numbers as well, except also complex analysis is heaven', 6),
|
||||
('29', '29.7', 'A few harder problems to think about', 7),
|
||||
('30', '30.1', 'Uniform continuity', 1),
|
||||
('30', '30.2', 'Dense sets and extension', 2),
|
||||
('30', '30.3', 'Defining the Riemann integral', 3),
|
||||
('30', '30.4', 'Meshes', 4),
|
||||
('30', '30.5', 'A few harder problems to think about', 5),
|
||||
('31', '31.1', 'The nicest functions on earth', 1),
|
||||
('31', '31.2', 'Complex differentiation', 2),
|
||||
('31', '31.3', 'Contour integrals', 3),
|
||||
('31', '31.4', 'Cauchy-Goursat theorem', 4),
|
||||
('31', '31.5', 'Cauchy''s integral theorem', 5),
|
||||
('31', '31.6', 'Holomorphic functions are analytic', 6),
|
||||
('31', '31.7', 'Optional: Proof that holomorphic functions are analytic', 7),
|
||||
('31', '31.8', 'A few harder problems to think about', 8),
|
||||
('32', '32.1', 'The second nicest functions on earth', 1),
|
||||
('32', '32.2', 'Meromorphic functions', 2),
|
||||
('32', '32.3', 'Winding numbers and the residue theorem', 3),
|
||||
('32', '32.4', 'Argument principle', 4),
|
||||
('32', '32.5', 'Digression: the Argument Principle viewed geometrically', 5),
|
||||
('32', '32.6', 'Philosophy: why are holomorphic functions so nice?', 6),
|
||||
('32', '32.7', 'A few harder problems to think about', 7),
|
||||
('33', '33.1', 'Motivation: square root of a complex number', 1),
|
||||
('33', '33.2', 'Square roots of holomorphic functions', 2),
|
||||
('33', '33.3', 'Covering projections', 3),
|
||||
('33', '33.4', 'Complex logarithms', 4),
|
||||
('33', '33.5', 'Some special cases', 5),
|
||||
('33', '33.6', 'A few harder problems to think about', 6),
|
||||
('34', '34.1', 'The Game Plan', 1),
|
||||
('34', '34.2', 'Step 1: The Simplest Case', 2),
|
||||
('34', '34.3', 'Step 2: Nested Roots', 3),
|
||||
('34', '34.4', 'Step 3: Normal Groups', 4),
|
||||
('34', '34.5', 'Summary', 5),
|
||||
('34', '34.6', 'A few harder problems to think about', 6),
|
||||
('35', '35.1', 'Letter connotations', 1),
|
||||
('35', '35.2', 'Motivating measure spaces via random variables', 2),
|
||||
('35', '35.3', 'Motivating measure spaces geometrically', 3),
|
||||
('35', '35.4', 'σ-algebras and measurable spaces', 4),
|
||||
('35', '35.5', 'Measure spaces', 5),
|
||||
('35', '35.6', 'A hint of Banach-Tarski', 6),
|
||||
('35', '35.7', 'Measurable functions', 7),
|
||||
('35', '35.8', 'On the word ``almost''''', 8),
|
||||
('35', '35.9', 'A few harder problems to think about', 9),
|
||||
('36', '36.1', 'Pre-measures', 1),
|
||||
('36', '36.2', 'Outer measures', 2),
|
||||
('36', '36.3', 'Carathéodory extension for outer measures', 3),
|
||||
('36', '36.4', 'Defining the Lebesgue measure', 4),
|
||||
('36', '36.5', 'A fourth row: Carathéodory for pre-measures', 5),
|
||||
('36', '36.6', 'From now on, we assume the Borel measure', 6),
|
||||
('36', '36.7', 'A few harder problems to think about', 7),
|
||||
('37', '37.1', 'The definition', 1),
|
||||
('37', '37.2', 'An equivalent definition', 2),
|
||||
('37', '37.3', 'Relation to Riemann integrals (or: actually computing Lebesgue integrals)', 3),
|
||||
('37', '37.4', 'A few harder problems to think about', 4),
|
||||
('38', '38.1', 'Motivating limit interchange', 1),
|
||||
('38', '38.2', 'Overview', 2),
|
||||
('38', '38.3', 'Fatou''s lemma', 3),
|
||||
('38', '38.4', 'Everything else', 4),
|
||||
('38', '38.5', 'Fubini and Tonelli', 5),
|
||||
('38', '38.6', 'A few harder problems to think about', 6),
|
||||
('39', '39.1', 'LCA groups', 1),
|
||||
('39', '39.2', 'The Pontryagin dual', 2),
|
||||
('39', '39.3', 'The orthonormal basis in the compact case', 3),
|
||||
('39', '39.4', 'The Fourier transform of the non-compact case', 4),
|
||||
('39', '39.5', 'Summary', 5),
|
||||
('39', '39.6', 'A few harder problems to think about', 6),
|
||||
('40', '40.1', 'Random variables', 1),
|
||||
('40', '40.2', 'Distribution functions', 2),
|
||||
('40', '40.3', 'Examples of random variables', 3),
|
||||
('40', '40.4', 'Characteristic functions', 4),
|
||||
('40', '40.5', 'Independent random variables', 5),
|
||||
('40', '40.6', 'A few harder problems to think about', 6),
|
||||
('41', '41.1', 'Notions of convergence', 1),
|
||||
('41', '41.2', 'Weak law of large numbers', 2),
|
||||
('41', '41.3', 'Strong law of large numbers', 3),
|
||||
('41', '41.4', 'A few harder problems to think about', 4),
|
||||
('42', '42.1', 'How to make money almost surely', 1),
|
||||
('42', '42.2', 'Sub-σ-algebras and filtrations', 2),
|
||||
('42', '42.3', 'Conditional expectation', 3),
|
||||
('42', '42.4', 'Supermartingales', 4),
|
||||
('42', '42.5', 'Optional stopping', 5),
|
||||
('42', '42.6', 'Fun applications of optional stopping (TO DO)', 6),
|
||||
('42', '42.7', 'A few harder problems to think about', 7),
|
||||
('43', '43.1', 'The total derivative', 1),
|
||||
('43', '43.2', 'The projection principle', 2),
|
||||
('43', '43.3', 'Total and partial derivatives', 3),
|
||||
('43', '43.4', '(Optional) A word on higher derivatives', 4),
|
||||
('43', '43.5', 'Towards differential forms', 5),
|
||||
('43', '43.6', 'A few harder problems to think about', 6),
|
||||
('44', '44.1', 'Pictures of differential forms', 1),
|
||||
('44', '44.2', 'Pictures of exterior derivatives', 2),
|
||||
('44', '44.3', 'Differential forms', 3),
|
||||
('44', '44.4', 'Exterior derivatives', 4),
|
||||
('44', '44.5', 'Digression: k(V) versus (k(V))', 5),
|
||||
('44', '44.6', 'Tangential remark: Arc length ds is not a 1-form', 6),
|
||||
('44', '44.7', 'Closed and exact forms', 7),
|
||||
('44', '44.8', 'A few harder problems to think about', 8),
|
||||
('45', '45.1', 'Motivation: line integrals', 1),
|
||||
('45', '45.2', 'Pullbacks', 2),
|
||||
('45', '45.3', 'Cells', 3),
|
||||
('45', '45.4', 'Boundaries', 4),
|
||||
('45', '45.5', 'Stokes'' theorem', 5),
|
||||
('45', '45.6', 'Back to Earth: A comparison to what you learned in vector calculus', 6),
|
||||
('45', '45.7', 'A few harder problems to think about', 7),
|
||||
('46', '46.1', 'Topological manifolds', 1),
|
||||
('46', '46.2', 'Smooth manifolds', 2),
|
||||
('46', '46.3', 'Regular value theorem', 3),
|
||||
('46', '46.4', 'Differential forms on manifolds', 4),
|
||||
('46', '46.5', 'Orientations', 5),
|
||||
('46', '46.6', 'Stokes'' theorem for manifolds', 6),
|
||||
('46', '46.7', '(Optional) The tangent and cotangent space', 7),
|
||||
('46', '46.8', 'A few harder problems to think about', 8),
|
||||
('47', '47.1', 'Complex structures', 1),
|
||||
('47', '47.2', 'Riemann surface', 2),
|
||||
('47', '47.3', 'Complex manifold', 3),
|
||||
('47', '47.4', 'Examples of Riemann surfaces', 4),
|
||||
('48', '48.1', 'Definition', 1),
|
||||
('48', '48.2', 'Functions to the Riemann sphere', 2),
|
||||
('48', '48.3', 'Some other nice properties', 3),
|
||||
('48', '48.4', 'Multiplicity of a map', 4),
|
||||
('48', '48.5', 'The sum of the orders of a meromorphic function', 5),
|
||||
('48', '48.6', 'The Hurwitz formula', 6),
|
||||
('48', '48.7', 'The identity theorem', 7),
|
||||
('49', '49.1', 'Affine plane curves', 1),
|
||||
('49', '49.2', 'The projective line CP1', 2),
|
||||
('49', '49.3', 'Projective plane curves', 3),
|
||||
('49', '49.4', 'Filling in the holes', 4),
|
||||
('49', '49.5', 'Nodes of a plane curve', 5),
|
||||
('50', '50.1', 'Differential form on C', 1),
|
||||
('50', '50.2', 'Visualization of differential forms', 2),
|
||||
('51', '51.1', 'Motivation', 1),
|
||||
('51', '51.2', 'Divisors', 2),
|
||||
('51', '51.3', 'Degree of a divisor', 3),
|
||||
('51', '51.4', 'The principal divisor of a meromorphic function', 4),
|
||||
('51', '51.5', 'The Riemann-Roch theorem', 5),
|
||||
('52', '52.1', 'Overview', 1),
|
||||
('52', '52.2', 'Definition', 2),
|
||||
('52', '52.3', 'Visualizing a line bundle', 3),
|
||||
('52', '52.4', 'Morphisms between line bundles', 4),
|
||||
('52', '52.5', 'Relation to invertible sheaves', 5),
|
||||
('53', '53.1', 'Motivation from high school algebra', 1),
|
||||
('53', '53.2', 'Algebraic numbers and algebraic integers', 2),
|
||||
('53', '53.3', 'Number fields', 3),
|
||||
('53', '53.4', 'Primitive element theorem, and monogenic extensions', 4),
|
||||
('53', '53.5', 'A few harder problems to think about', 5),
|
||||
('54', '54.1', 'Norms and traces', 1),
|
||||
('54', '54.2', 'The ring of integers', 2),
|
||||
('54', '54.3', 'On monogenic extensions', 3),
|
||||
('54', '54.4', 'A few harder problems to think about', 4),
|
||||
('55', '55.1', 'Motivation', 1),
|
||||
('55', '55.2', 'Ideal arithmetic', 2),
|
||||
('55', '55.3', 'Dedekind domains', 3),
|
||||
('55', '55.4', 'Unique factorization works', 4),
|
||||
('55', '55.5', 'The factoring algorithm', 5),
|
||||
('55', '55.6', 'Fractional ideals', 6),
|
||||
('55', '55.7', 'The ideal norm', 7),
|
||||
('55', '55.8', 'A few harder problems to think about', 8),
|
||||
('56', '56.1', 'The class group', 1),
|
||||
('56', '56.2', 'The discriminant of a number field', 2),
|
||||
('56', '56.3', 'The signature of a number field', 3),
|
||||
('56', '56.4', 'Minkowski''s theorem', 4),
|
||||
('56', '56.5', 'The trap box', 5),
|
||||
('56', '56.6', 'The Minkowski bound', 6),
|
||||
('56', '56.7', 'The class group is finite', 7),
|
||||
('56', '56.8', 'Computation of class numbers', 8),
|
||||
('56', '56.9', 'Optional: Proof that OK is a free Z-module', 9),
|
||||
('56', '56.10', 'A few harder problems to think about', 10),
|
||||
('57', '57.1', 'A few harder problems to think about', 1),
|
||||
('58', '58.1', 'Units', 1),
|
||||
('58', '58.2', 'Dirichlet''s unit theorem', 2),
|
||||
('58', '58.3', 'Finding fundamental units', 3),
|
||||
('58', '58.4', 'Pell''s equation', 4),
|
||||
('58', '58.5', 'A few harder problems to think about', 5),
|
||||
('59', '59.1', 'Motivation', 1),
|
||||
('59', '59.2', 'Field extensions, algebraic extension, and splitting fields', 2),
|
||||
('59', '59.3', 'Embeddings into algebraic closures for number fields', 3),
|
||||
('59', '59.4', 'Everyone hates characteristic 2: separable vs irreducible', 4),
|
||||
('59', '59.5', 'Automorphism groups and Galois extensions', 5),
|
||||
('59', '59.6', 'Fundamental theorem of Galois theory', 6),
|
||||
('59', '59.7', 'A few harder problems to think about', 7),
|
||||
('59', '59.8', '(Optional) Proof that Galois extensions are splitting', 8),
|
||||
('60', '60.1', 'Example of a finite field', 1),
|
||||
('60', '60.2', 'Finite fields have prime power order', 2),
|
||||
('60', '60.3', 'All finite fields are isomorphic', 3),
|
||||
('60', '60.4', 'The Galois theory of finite fields', 4),
|
||||
('60', '60.5', 'Extra: The multiplicative group of a finite field', 5),
|
||||
('60', '60.6', 'A few harder problems to think about', 6),
|
||||
('61', '61.1', 'Ramified / inert / split primes', 1),
|
||||
('61', '61.2', 'Primes ramify if and only if they divide K', 2),
|
||||
('61', '61.3', 'Inertial degrees', 3),
|
||||
('61', '61.4', 'The magic of Galois extensions', 4),
|
||||
('61', '61.5', '(Optional) Decomposition and inertia groups', 5),
|
||||
('61', '61.6', 'Tangential remark: more general Galois extensions', 6),
|
||||
('61', '61.7', 'A few harder problems to think about', 7),
|
||||
('62', '62.1', 'Frobenius elements', 1),
|
||||
('62', '62.2', 'Conjugacy classes', 2),
|
||||
('62', '62.3', 'Chebotarev density theorem', 3),
|
||||
('62', '62.4', 'Example: Frobenius elements of cyclotomic fields', 4),
|
||||
('62', '62.5', 'Frobenius elements behave well with restriction', 5),
|
||||
('62', '62.6', 'Application: Quadratic reciprocity', 6),
|
||||
('62', '62.7', 'Frobenius elements control factorization', 7),
|
||||
('62', '62.8', 'Example application: IMO 2003 problem 6', 8),
|
||||
('62', '62.9', 'A few harder problems to think about', 9),
|
||||
('63', '63.1', 'Overview', 1),
|
||||
('63', '63.2', 'Infinite primes', 2),
|
||||
('63', '63.3', 'Modular arithmetic with infinite primes', 3),
|
||||
('63', '63.4', 'Infinite primes in extensions', 4),
|
||||
('63', '63.5', 'Frobenius element and Artin symbol', 5),
|
||||
('63', '63.6', 'Artin reciprocity', 6),
|
||||
('63', '63.7', 'Application: Generalization of sum of two squares', 7),
|
||||
('63', '63.8', 'A few harder problems to think about', 8),
|
||||
('64', '64.1', 'Spheres', 1),
|
||||
('64', '64.2', 'Quotient topology', 2),
|
||||
('64', '64.3', 'Product topology', 3),
|
||||
('64', '64.4', 'Disjoint union and wedge sum', 4),
|
||||
('64', '64.5', 'CW complexes', 5),
|
||||
('64', '64.6', 'The torus, Klein bottle, RPn, CPn', 6),
|
||||
('64', '64.7', 'A few harder problems to think about', 7),
|
||||
('65', '65.1', 'Fusing paths together', 1),
|
||||
('65', '65.2', 'Fundamental groups', 2),
|
||||
('65', '65.3', 'Fundamental groups are invariant under homeomorphism', 3),
|
||||
('65', '65.4', 'Higher homotopy groups', 4),
|
||||
('65', '65.5', 'Homotopy equivalent spaces', 5),
|
||||
('65', '65.6', 'The pointed homotopy category', 6),
|
||||
('65', '65.7', 'A few harder problems to think about', 7),
|
||||
('66', '66.1', 'Even coverings and covering projections', 1),
|
||||
('66', '66.2', 'Lifting theorem', 2),
|
||||
('66', '66.3', 'Lifting correspondence', 3),
|
||||
('66', '66.4', 'Regular coverings', 4),
|
||||
('66', '66.5', 'The algebra of fundamental groups', 5),
|
||||
('66', '66.6', 'A few harder problems to think about', 6),
|
||||
('67', '67.1', 'Motivation: isomorphisms', 1),
|
||||
('67', '67.2', 'Categories, and examples thereof', 2),
|
||||
('67', '67.3', 'Special objects in categories', 3),
|
||||
('67', '67.4', 'Binary products', 4),
|
||||
('67', '67.5', 'Monic and epic maps', 5),
|
||||
('67', '67.6', 'A few harder problems to think about', 6),
|
||||
('68', '68.1', 'Many examples of functors', 1),
|
||||
('68', '68.2', 'Covariant functors', 2),
|
||||
('68', '68.3', 'Covariant functors as indexed family of objects', 3),
|
||||
('68', '68.4', 'Contravariant functors', 4),
|
||||
('68', '68.5', 'Equivalence of categories', 5),
|
||||
('68', '68.6', '(Optional) Natural transformations', 6),
|
||||
('68', '68.7', '(Optional) The Yoneda lemma', 7),
|
||||
('68', '68.8', 'A few harder problems to think about', 8),
|
||||
('69', '69.1', 'Equalizers', 1),
|
||||
('69', '69.2', 'Pullback squares (TO DO)', 2),
|
||||
('69', '69.3', 'Limits', 3),
|
||||
('69', '69.4', 'A few harder problems to think about', 4),
|
||||
('70', '70.1', 'Zero objects, kernels, cokernels, and images', 1),
|
||||
('70', '70.2', 'Additive and abelian categories', 2),
|
||||
('70', '70.3', 'Exact sequences', 3),
|
||||
('70', '70.4', 'The Freyd-Mitchell embedding theorem', 4),
|
||||
('70', '70.5', 'Breaking long exact sequences', 5),
|
||||
('70', '70.6', 'A few harder problems to think about', 6),
|
||||
('71', '71.1', 'Simplices and boundaries', 1),
|
||||
('71', '71.2', 'The singular homology groups', 2),
|
||||
('71', '71.3', 'The homology functor and chain complexes', 3),
|
||||
('71', '71.4', 'More examples of chain complexes', 4),
|
||||
('71', '71.5', 'A few harder problems to think about', 5),
|
||||
('72', '72.1', 'Short exact sequences and four examples', 1),
|
||||
('72', '72.2', 'The long exact sequence of homology groups', 2),
|
||||
('72', '72.3', 'The Mayer-Vietoris sequence', 3),
|
||||
('72', '72.4', 'A few harder problems to think about', 4),
|
||||
('73', '73.1', 'Motivation', 1),
|
||||
('73', '73.2', 'The long exact sequences', 2),
|
||||
('73', '73.3', 'The category of pairs', 3),
|
||||
('73', '73.4', 'Excision', 4),
|
||||
('73', '73.5', 'Some applications', 5),
|
||||
('73', '73.6', 'Invariance of dimension', 6),
|
||||
('73', '73.7', 'A few harder problems to think about', 7),
|
||||
('74', '74.1', 'Degrees', 1),
|
||||
('74', '74.2', 'Cellular chain complex', 2),
|
||||
('74', '74.3', 'Digression: why are the homology groups equal?', 3),
|
||||
('74', '74.4', 'Application: Euler characteristic via Betti numbers', 4),
|
||||
('74', '74.5', 'The cellular boundary formula', 5),
|
||||
('74', '74.6', 'A few harder problems to think about', 6),
|
||||
('75', '75.1', 'Cochain complexes', 1),
|
||||
('75', '75.2', 'Cohomology of spaces', 2),
|
||||
('75', '75.3', 'Cohomology of spaces is functorial', 3),
|
||||
('75', '75.4', 'Universal coefficient theorem', 4),
|
||||
('75', '75.5', 'Explanation for universal coefficient theorem', 5),
|
||||
('75', '75.6', 'Example computation of cohomology groups', 6),
|
||||
('75', '75.7', 'Visualization of cohomology groups', 7),
|
||||
('75', '75.8', 'Relative cohomology groups', 8),
|
||||
('75', '75.9', 'A few harder problems to think about', 9),
|
||||
('76', '76.1', 'Poincaré duality', 1),
|
||||
('76', '76.2', 'de Rham cohomology', 2),
|
||||
('76', '76.3', 'Graded rings', 3),
|
||||
('76', '76.4', 'Cup products', 4),
|
||||
('76', '76.5', 'Relative cohomology pseudo-rings', 5),
|
||||
('76', '76.6', 'Wedge sums', 6),
|
||||
('76', '76.7', 'Cross product', 7),
|
||||
('76', '76.8', 'Künneth formula', 8),
|
||||
('76', '76.9', 'A few harder problems to think about', 9),
|
||||
('77', '77.1', 'Affine varieties', 1),
|
||||
('77', '77.2', 'Naming affine varieties via ideals', 2),
|
||||
('77', '77.3', 'Radical ideals and Hilbert''s Nullstellensatz', 3),
|
||||
('77', '77.4', 'Pictures of varieties in A1', 4),
|
||||
('77', '77.5', 'Prime ideals correspond to irreducible affine varieties', 5),
|
||||
('77', '77.6', 'Pictures in A2 and A3', 6),
|
||||
('77', '77.7', 'Maximal ideals', 7),
|
||||
('77', '77.8', 'Motivating schemes with non-radical ideals', 8),
|
||||
('77', '77.9', 'A few harder problems to think about', 9),
|
||||
('78', '78.1', 'Synopsis', 1),
|
||||
('78', '78.2', 'The Zariski topology on An', 2),
|
||||
('78', '78.3', 'The Zariski topology on affine varieties', 3),
|
||||
('78', '78.4', 'Coordinate rings', 4),
|
||||
('78', '78.5', 'The sheaf of regular functions', 5),
|
||||
('78', '78.6', 'Regular functions on distinguished open sets', 6),
|
||||
('78', '78.7', 'Baby ringed spaces', 7),
|
||||
('78', '78.8', 'A few harder problems to think about', 8),
|
||||
('79', '79.1', 'Graded rings', 1),
|
||||
('79', '79.2', 'The ambient space', 2),
|
||||
('79', '79.3', 'Homogeneous ideals', 3),
|
||||
('79', '79.4', 'As ringed spaces', 4),
|
||||
('79', '79.5', 'Examples of regular functions', 5),
|
||||
('79', '79.6', 'A few harder problems to think about', 6),
|
||||
('80', '80.1', 'Non-radical ideals', 1),
|
||||
('80', '80.2', 'Hilbert functions of finitely many points', 2),
|
||||
('80', '80.3', 'Hilbert polynomials', 3),
|
||||
('80', '80.4', 'Bézout''s theorem', 4),
|
||||
('80', '80.5', 'Applications', 5),
|
||||
('80', '80.6', 'A few harder problems to think about', 6),
|
||||
('81', '81.1', 'Defining morphisms of baby ringed spaces', 1),
|
||||
('81', '81.2', 'Classifying the simplest examples', 2),
|
||||
('81', '81.3', 'Some more applications and examples', 3),
|
||||
('81', '81.4', 'The hyperbola effect', 4),
|
||||
('81', '81.5', 'A few harder problems to think about', 5),
|
||||
('82', '82.1', 'Motivation and warnings', 1),
|
||||
('82', '82.2', 'Pre-sheaves', 2),
|
||||
('82', '82.3', 'Stalks and germs', 3),
|
||||
('82', '82.4', 'Sheaves', 4),
|
||||
('82', '82.5', 'For sheaves, sections ``are'''' sequences of germs', 5),
|
||||
('82', '82.6', 'Sheafification (optional)', 6),
|
||||
('82', '82.7', 'A few harder problems to think about', 7),
|
||||
('83', '83.1', 'Spoilers', 1),
|
||||
('83', '83.2', 'The definition', 2),
|
||||
('83', '83.3', 'Localization away from an element', 3),
|
||||
('83', '83.4', 'Localization at a prime ideal', 4),
|
||||
('83', '83.5', 'Prime ideals of localizations', 5),
|
||||
('83', '83.6', 'Prime ideals of quotients', 6),
|
||||
('83', '83.7', 'Localization commutes with quotients', 7),
|
||||
('83', '83.8', 'A few harder problems to think about', 8),
|
||||
('84', '84.1', 'Some more advertising', 1),
|
||||
('84', '84.2', 'The set of points', 2),
|
||||
('84', '84.3', 'The Zariski topology on the spectrum', 3),
|
||||
('84', '84.4', 'Krull dimension', 4),
|
||||
('84', '84.5', 'On radicals', 5),
|
||||
('84', '84.6', 'A few harder problems to think about', 6),
|
||||
('85', '85.1', 'A useless definition of the structure sheaf', 1),
|
||||
('85', '85.2', 'The value of distinguished open sets (or: how to actually compute sections)', 2),
|
||||
('85', '85.3', 'The stalks of the structure sheaf', 3),
|
||||
('85', '85.4', 'Local rings and residue fields: linking germs to values', 4),
|
||||
('85', '85.5', 'Recap', 5),
|
||||
('85', '85.6', 'Functions are determined by germs, not values', 6),
|
||||
('85', '85.7', 'A few harder problems to think about', 7),
|
||||
('86', '86.1', 'Example: Spec k, a single point', 1),
|
||||
('86', '86.2', 'Spec C[x], a one-dimensional line', 2),
|
||||
('86', '86.3', 'Spec R[x], a one-dimensional line with complex conjugates glued (no fear nullstellensatz)', 3),
|
||||
('86', '86.4', 'Spec k[x], over any ground field', 4),
|
||||
('86', '86.5', 'Spec Z, a one-dimensional scheme', 5),
|
||||
('86', '86.6', 'Spec k[x] / (x2-7x+12), two points', 6),
|
||||
('86', '86.7', 'Spec k[x]/(x2), the double point', 7),
|
||||
('86', '86.8', 'Spec k[x]/(x3-5x2), a double point and a single point', 8),
|
||||
('86', '86.9', 'Spec Z/60Z, a scheme with three points', 9),
|
||||
('86', '86.10', 'Spec k[x,y], the two-dimensional plane', 10),
|
||||
('86', '86.11', 'Spec Z[x], a two-dimensional scheme, and Mumford''s picture', 11),
|
||||
('86', '86.12', 'Spec k[x,y]/(y-x2), the parabola', 12),
|
||||
('86', '86.13', 'Spec Z[i], the Gaussian integers (one-dimensional)', 13),
|
||||
('86', '86.14', 'Long example: Spec k[x,y]/(xy), two axes', 14),
|
||||
('86', '86.15', 'Spec k[x,x-1], the punctured line (or hyperbola)', 15),
|
||||
('86', '86.16', 'Spec k[x](x), zooming in to the origin of the line', 16),
|
||||
('86', '86.17', 'Spec k[x,y](x,y), zooming in to the origin of the plane', 17),
|
||||
('86', '86.18', 'Spec k[x,y](0) = Spec k(x,y), the stalk above the generic point', 18),
|
||||
('86', '86.19', 'A few harder problems to think about', 19),
|
||||
('87', '87.1', 'Morphisms of ringed spaces via sections', 1),
|
||||
('87', '87.2', 'Morphisms of ringed spaces via stalks', 2),
|
||||
('87', '87.3', 'Morphisms of locally ringed spaces', 3),
|
||||
('87', '87.4', 'A few examples of morphisms between affine schemes', 4),
|
||||
('87', '87.5', 'The big theorem', 5),
|
||||
('87', '87.6', 'More examples of scheme morphisms', 6),
|
||||
('87', '87.7', 'A little bit on non-affine schemes', 7),
|
||||
('87', '87.8', 'Where to go from here', 8),
|
||||
('87', '87.9', 'A few harder problems to think about', 9),
|
||||
('88', '88.1', 'Let''s construct a monster', 1),
|
||||
('88', '88.2', 'Review of finite induction', 2),
|
||||
('88', '88.3', 'Transfinite induction', 3),
|
||||
('88', '88.4', 'Wrapping up functional equations', 4),
|
||||
('88', '88.5', 'Zorn''s lemma', 5),
|
||||
('88', '88.6', 'A few harder problems to think about', 6),
|
||||
('89', '89.1', 'The ultimate functional equation', 1),
|
||||
('89', '89.2', 'Cantor''s paradox', 2),
|
||||
('89', '89.3', 'The language of set theory', 3),
|
||||
('89', '89.4', 'The axioms of ZFC', 4),
|
||||
('89', '89.5', 'Encoding', 5),
|
||||
('89', '89.6', 'Choice and well-ordering', 6),
|
||||
('89', '89.7', 'Sets vs classes', 7),
|
||||
('89', '89.8', 'A few harder problems to think about', 8),
|
||||
('90', '90.1', 'Counting for preschoolers', 1),
|
||||
('90', '90.2', 'Counting for set theorists', 2),
|
||||
('90', '90.3', 'Definition of an ordinal', 3),
|
||||
('90', '90.4', 'Ordinals are ``tall''''', 4),
|
||||
('90', '90.5', 'Transfinite induction and recursion', 5),
|
||||
('90', '90.6', 'Ordinal arithmetic', 6),
|
||||
('90', '90.7', 'The hierarchy of sets', 7),
|
||||
('90', '90.8', 'A few harder problems to think about', 8),
|
||||
('91', '91.1', 'Equinumerous sets and cardinals', 1),
|
||||
('91', '91.2', 'Cardinalities', 2),
|
||||
('91', '91.3', 'Aleph numbers', 3),
|
||||
('91', '91.4', 'Cardinal arithmetic', 4),
|
||||
('91', '91.5', 'Cardinal exponentiation', 5),
|
||||
('91', '91.6', 'Cofinality', 6),
|
||||
('91', '91.7', 'Inaccessible cardinals', 7),
|
||||
('91', '91.8', 'A few harder problems to think about', 8),
|
||||
('92', '92.1', 'Models', 1),
|
||||
('92', '92.2', 'Sentences and satisfaction', 2),
|
||||
('92', '92.3', 'The Levy hierarchy', 3),
|
||||
('92', '92.4', 'Substructures, and Tarski-Vaught', 4),
|
||||
('92', '92.5', 'Obtaining the axioms of ZFC', 5),
|
||||
('92', '92.6', 'Mostowski collapse', 6),
|
||||
('92', '92.7', 'Adding an inaccessible', 7),
|
||||
('92', '92.8', 'FAQ''s on countable models', 8),
|
||||
('92', '92.9', 'Picturing inner models', 9),
|
||||
('92', '92.10', 'A few harder problems to think about', 10),
|
||||
('93', '93.1', 'Setting up posets', 1),
|
||||
('93', '93.2', 'More properties of posets', 2),
|
||||
('93', '93.3', 'Names, and the generic extension', 3),
|
||||
('93', '93.4', 'Fundamental theorem of forcing', 4),
|
||||
('93', '93.5', '(Optional) Defining the relation', 5),
|
||||
('93', '93.6', 'The remaining axioms', 6),
|
||||
('93', '93.7', 'A few harder problems to think about', 7),
|
||||
('94', '94.1', 'Adding in reals', 1),
|
||||
('94', '94.2', 'The countable chain condition', 2),
|
||||
('94', '94.3', 'Preserving cardinals', 3),
|
||||
('94', '94.4', 'Infinite combinatorics', 4),
|
||||
('94', '94.5', 'A few harder problems to think about', 5),
|
||||
('A', 'A.1', 'Basic algebra and topology', 1),
|
||||
('A', 'A.2', 'Second-year topics', 2),
|
||||
('A', 'A.3', 'Advanced topics', 3),
|
||||
('A', 'A.4', 'Topics not in Napkin', 4),
|
||||
('D', 'D.1', 'General', 1),
|
||||
('D', 'D.2', 'Functions and sets', 2),
|
||||
('D', 'D.3', 'Abstract and linear algebra', 3),
|
||||
('D', 'D.4', 'Quantum computation', 4),
|
||||
('D', 'D.5', 'Topology and real/complex analysis', 5),
|
||||
('D', 'D.6', 'Measure theory and probability', 6),
|
||||
('D', 'D.7', 'Algebraic topology', 7),
|
||||
('D', 'D.8', 'Category theory', 8),
|
||||
('D', 'D.9', 'Differential geometry', 9),
|
||||
('D', 'D.10', 'Algebraic number theory', 10),
|
||||
('D', 'D.11', 'Representation theory', 11),
|
||||
('D', 'D.12', 'Algebraic geometry', 12),
|
||||
('D', 'D.13', 'Set theory', 13),
|
||||
('E', 'E.1', 'Sets', 1),
|
||||
('E', 'E.2', 'Functions', 2),
|
||||
('E', 'E.3', 'Equivalence relations', 3)
|
||||
) AS v(chapter, key, title, ord) JOIN "math"."chapters" c ON c.key = v.chapter;
|
||||
--> statement-breakpoint
|
||||
-- Progress bookkeeping, so a plain UPDATE from a bot or psql behaves like the site:
|
||||
-- status changes stamp started_at/completed_at, and every status, exercise, note and section
|
||||
-- change is logged to math.activity (source from the transaction-local math.source setting,
|
||||
-- 'sql' when unset; math.apply_progress sets it).
|
||||
CREATE FUNCTION "math"."chapters_stamp"() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
IF NEW.status = 'not_started' THEN
|
||||
NEW.started_at := NULL;
|
||||
NEW.completed_at := NULL;
|
||||
ELSE
|
||||
IF NEW.status IN ('reading', 'exercises', 'done') AND NEW.started_at IS NULL THEN
|
||||
NEW.started_at := now();
|
||||
END IF;
|
||||
IF NEW.status = 'done' THEN
|
||||
NEW.completed_at := coalesce(NEW.completed_at, now());
|
||||
ELSE
|
||||
NEW.completed_at := NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE FUNCTION "math"."chapters_log"() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
src text := coalesce(nullif(current_setting('math.source', true), ''), 'sql');
|
||||
BEGIN
|
||||
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
INSERT INTO math.activity (chapter_id, kind, from_value, to_value, source)
|
||||
VALUES (NEW.id, 'status', OLD.status, NEW.status, src);
|
||||
END IF;
|
||||
IF NEW.exercises_done IS DISTINCT FROM OLD.exercises_done THEN
|
||||
INSERT INTO math.activity (chapter_id, kind, from_value, to_value, delta, source)
|
||||
VALUES (NEW.id, 'exercises', OLD.exercises_done::text, NEW.exercises_done::text,
|
||||
NEW.exercises_done - OLD.exercises_done, src);
|
||||
END IF;
|
||||
IF coalesce(NEW.notes, '') IS DISTINCT FROM coalesce(OLD.notes, '') THEN
|
||||
INSERT INTO math.activity (chapter_id, kind, to_value, source)
|
||||
VALUES (NEW.id, 'note', left(NEW.notes, 200), src);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE FUNCTION "math"."sections_log"() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (NEW.done_at IS NULL) IS DISTINCT FROM (OLD.done_at IS NULL) THEN
|
||||
INSERT INTO math.activity (chapter_id, section_id, kind, to_value, source)
|
||||
VALUES (NEW.chapter_id, NEW.id, 'section', CASE WHEN NEW.done_at IS NULL THEN 'undone' ELSE 'done' END,
|
||||
coalesce(nullif(current_setting('math.source', true), ''), 'sql'));
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER "chapters_stamp" BEFORE UPDATE ON "math"."chapters"
|
||||
FOR EACH ROW EXECUTE FUNCTION "math"."chapters_stamp"();
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER "chapters_log" AFTER UPDATE ON "math"."chapters"
|
||||
FOR EACH ROW EXECUTE FUNCTION "math"."chapters_log"();
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER "sections_log" AFTER UPDATE OF "done_at" ON "math"."sections"
|
||||
FOR EACH ROW EXECUTE FUNCTION "math"."sections_log"();
|
||||
--> statement-breakpoint
|
||||
-- One progress update as JSON (or an array of them), the format bots send:
|
||||
-- {"chapter": "3", "status": "reading" | "exercises" | "done" | "skipped" | "not_started",
|
||||
-- "exercises_done": 4 (absolute) or "exercises_add": 1 (relative),
|
||||
-- "notes": "short note" ("" clears),
|
||||
-- "sections_done": ["3.1", "3.2"] or "all", "sections_undone": [...] or "all",
|
||||
-- "source": "bot"}
|
||||
-- Every field but "chapter" is optional. Returns the updated chapter(s) as JSON.
|
||||
CREATE FUNCTION "math"."apply_progress"(p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
ch_id integer;
|
||||
k text;
|
||||
item jsonb;
|
||||
res jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
IF jsonb_typeof(p) = 'array' THEN
|
||||
FOR item IN SELECT * FROM jsonb_array_elements(p) LOOP
|
||||
res := res || jsonb_build_array(math.apply_progress(item));
|
||||
END LOOP;
|
||||
RETURN res;
|
||||
END IF;
|
||||
k := btrim(p->>'chapter');
|
||||
SELECT id INTO ch_id FROM math.chapters WHERE key = upper(k);
|
||||
IF ch_id IS NULL THEN
|
||||
RAISE EXCEPTION 'math.apply_progress: unknown chapter %', coalesce(k, '(missing)');
|
||||
END IF;
|
||||
PERFORM set_config('math.source', coalesce(nullif(p->>'source', ''), 'bot'), true);
|
||||
UPDATE math.chapters SET
|
||||
status = coalesce(nullif(p->>'status', ''), status),
|
||||
exercises_done = CASE
|
||||
WHEN p ? 'exercises_done' THEN greatest(0, (p->>'exercises_done')::int)
|
||||
WHEN p ? 'exercises_add' THEN greatest(0, exercises_done + (p->>'exercises_add')::int)
|
||||
ELSE exercises_done END,
|
||||
notes = CASE WHEN p ? 'notes' THEN nullif(btrim(p->>'notes'), '') ELSE notes END
|
||||
WHERE id = ch_id;
|
||||
IF p ? 'sections_done' THEN
|
||||
UPDATE math.sections SET done_at = now()
|
||||
WHERE chapter_id = ch_id AND done_at IS NULL
|
||||
AND (p->'sections_done' = '"all"'::jsonb
|
||||
OR (jsonb_typeof(p->'sections_done') = 'array'
|
||||
AND key IN (SELECT jsonb_array_elements_text(p->'sections_done'))));
|
||||
END IF;
|
||||
IF p ? 'sections_undone' THEN
|
||||
UPDATE math.sections SET done_at = NULL
|
||||
WHERE chapter_id = ch_id AND done_at IS NOT NULL
|
||||
AND (p->'sections_undone' = '"all"'::jsonb
|
||||
OR (jsonb_typeof(p->'sections_undone') = 'array'
|
||||
AND key IN (SELECT jsonb_array_elements_text(p->'sections_undone'))));
|
||||
END IF;
|
||||
SELECT jsonb_build_object(
|
||||
'chapter', c.key, 'title', c.title, 'status', c.status,
|
||||
'exercises_done', c.exercises_done, 'exercise_count', c.exercise_count,
|
||||
'started_at', c.started_at, 'completed_at', c.completed_at,
|
||||
'sections_done', (SELECT count(*) FROM math.sections s WHERE s.chapter_id = c.id AND s.done_at IS NOT NULL),
|
||||
'sections', (SELECT count(*) FROM math.sections s WHERE s.chapter_id = c.id))
|
||||
INTO item FROM math.chapters c WHERE c.id = ch_id;
|
||||
RETURN item;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Privacy: private by default (owner-only on the site, MATH_PRIVATE in #lib/math). Like notes
|
||||
-- and tasks, only the app role can read or write (no personal_ro). Grants only run on prod
|
||||
-- (migrations as `personal`).
|
||||
REVOKE ALL ON SCHEMA math FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA math TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA math TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA math TO personal_app;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA math TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA math
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA math
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
43310
drizzle/20261026104832_math/snapshot.json
Normal file
43310
drizzle/20261026104832_math/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1141
drizzle/20261026133106_plant/migration.sql
Normal file
1141
drizzle/20261026133106_plant/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
141
drizzle/20261026165544_radio/migration.sql
Normal file
141
drizzle/20261026165544_radio/migration.sql
Normal file
File diff suppressed because one or more lines are too long
45168
drizzle/20261026165544_radio/snapshot.json
Normal file
45168
drizzle/20261026165544_radio/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
||||
"wildlife:refresh": "scripts/wildlife-refresh.sh",
|
||||
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
|
||||
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
|
||||
"cs-prices:daily": "bun scripts/cs-prices.ts daily",
|
||||
"house:worker": "bun scripts/house-worker.ts",
|
||||
"house": "bun scripts/house-listing.ts",
|
||||
"house:add": "bun scripts/house-listing.ts add",
|
||||
@@ -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
399
scripts/cs-history.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* CS2 price history backfill into cs.daily_prices, under separate `*-history` streams so it never
|
||||
* mixes with the daily scrape. Resumable (cs.history_fetches) and throttled; run in the background:
|
||||
*
|
||||
* bun scripts/cs-history.ts fx ECB USD→EUR for every day since 2013 (Frankfurter),
|
||||
* weekends/holidays carry the last rate forward
|
||||
* bun scripts/cs-history.ts steam [max] Steam Community Market daily median sale price +
|
||||
* volume since listing, read from the public market
|
||||
* listing page (no login). One page holds every wear /
|
||||
* StatTrak / Souvenir sibling, so it is ~1 request per
|
||||
* skin, paced ~9 s apart with backoff on 429.
|
||||
* bun scripts/cs-history.ts csfloat-queue print CSFloat targets (NDJSON) for an off-box fetcher
|
||||
* bun scripts/cs-history.ts csfloat-import read CSFloat graph NDJSON on stdin and store it
|
||||
* bun scripts/cs-history.ts status coverage per history stream
|
||||
*
|
||||
* Steam prices arrive in the page's wallet currency (EUR here, `ecurrency` 3) and are stored as
|
||||
* is; USD streams (CSFloat) are converted per day with cs.fx_rates on read.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
|
||||
const mode = process.argv[2] ?? 'status';
|
||||
const ua = 'Mozilla/5.0';
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const log = (...args: unknown[]) => console.log(new Date().toISOString().slice(0, 19), ...args);
|
||||
|
||||
const streams = {
|
||||
steam: {
|
||||
source: 'steam-market',
|
||||
kind: 'sale-median-history',
|
||||
label: 'Steam Market daily median sale (history)'
|
||||
},
|
||||
csfloat: {
|
||||
source: 'csfloat',
|
||||
kind: 'sale-avg-history',
|
||||
label: 'CSFloat daily average sale (history)'
|
||||
}
|
||||
};
|
||||
async function streamId(source: string, currency: string, kind: string, label: string) {
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.price_streams (source, currency, kind, label)
|
||||
VALUES (${source}, ${currency}, ${kind}, ${label})
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label RETURNING id`;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
type Day = { day: string; cents: number; volume: number | null };
|
||||
async function store(stream: number, item: number, days: Day[]) {
|
||||
for (let offset = 0; offset < days.length; offset += 5000) {
|
||||
const chunk = days.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT ${item}::int, ${stream}::smallint, day, price_cents, volume
|
||||
FROM unnest(${chunk.map((d) => d.day)}::date[], ${chunk.map((d) => d.cents)}::int[],
|
||||
${chunk.map((d) => d.volume)}::int[]) AS x(day, price_cents, volume)
|
||||
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||
}
|
||||
}
|
||||
async function mark(item: number, source: string, days: Day[], error: string | null = null) {
|
||||
await sql`
|
||||
INSERT INTO cs.history_fetches (item_id, source, fetched_at, points, first_day, error)
|
||||
VALUES (${item}, ${source}, now(), ${days.length}, ${days[0]?.day ?? null}, ${error})
|
||||
ON CONFLICT (item_id, source) DO UPDATE SET fetched_at = now(), points = EXCLUDED.points,
|
||||
first_day = EXCLUDED.first_day, error = EXCLUDED.error`;
|
||||
}
|
||||
|
||||
async function fx() {
|
||||
const from = '2013-01-01';
|
||||
const to = new Date().toISOString().slice(0, 10);
|
||||
let rates: Record<string, { EUR: number }> | undefined;
|
||||
for (const url of [
|
||||
`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=EUR`,
|
||||
`https://api.frankfurter.app/${from}..${to}?from=USD&to=EUR`
|
||||
]) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (response.ok) {
|
||||
rates = ((await response.json()) as { rates: typeof rates }).rates;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rates) throw new Error('No FX history');
|
||||
const days: string[] = [];
|
||||
const values: number[] = [];
|
||||
let last: number | null = null;
|
||||
for (let t = Date.parse(from); t <= Date.parse(to); t += 86_400_000) {
|
||||
const day = new Date(t).toISOString().slice(0, 10);
|
||||
last = rates[day]?.EUR ?? last;
|
||||
if (last != null) {
|
||||
days.push(day);
|
||||
values.push(last);
|
||||
}
|
||||
}
|
||||
// Existing rows (the daily scrape's own rate) win.
|
||||
const result = await sql`
|
||||
INSERT INTO cs.fx_rates (day, usd_eur)
|
||||
SELECT * FROM unnest(${days}::date[], ${values}::numeric[]) ON CONFLICT (day) DO NOTHING`;
|
||||
log(
|
||||
`FX: ${Object.keys(rates).length} ECB fixings, ${result.count} days inserted (${days[0]}..${to})`
|
||||
);
|
||||
}
|
||||
|
||||
/** Every pricehistory query embedded in the listing page's SSR state. */
|
||||
function pageHistories(html: string) {
|
||||
const match = html.match(
|
||||
/<script type="application\/json" id="valve-ssr-data"[^>]*>([\s\S]*?)<\/script>/
|
||||
);
|
||||
if (!match) return null;
|
||||
const found = new Map<
|
||||
string,
|
||||
{ currency: number; prices: { time: number; price_median: number; purchases: number }[] }
|
||||
>();
|
||||
const walk = (node: unknown): void => {
|
||||
if (typeof node === 'string') {
|
||||
if (node.length > 2 && (node[0] === '{' || node[0] === '[')) {
|
||||
try {
|
||||
walk(JSON.parse(node));
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>;
|
||||
const key = record.queryKey;
|
||||
if (
|
||||
Array.isArray(key) &&
|
||||
key[0] === 'market' &&
|
||||
key[1] === 'pricehistory' &&
|
||||
key[2] === 730
|
||||
) {
|
||||
const data = (record.state as { data?: { ecurrency?: number; prices?: [] } } | undefined)
|
||||
?.data;
|
||||
if (data && Array.isArray(data.prices))
|
||||
found.set(String(key[3]), { currency: data.ecurrency ?? 0, prices: data.prices });
|
||||
}
|
||||
for (const value of Object.values(record)) walk(value);
|
||||
}
|
||||
};
|
||||
walk(JSON.parse(match[1]));
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Hourly points (last month) collapse to one row per UTC day, volume-weighted. */
|
||||
function daily(prices: { time: number; price_median: number; purchases: number }[]): Day[] {
|
||||
const byDay = new Map<string, { sum: number; weight: number; volume: number }>();
|
||||
for (const p of prices) {
|
||||
if (!Number.isFinite(p.price_median) || p.price_median <= 0) continue;
|
||||
const day = new Date(p.time * 1000).toISOString().slice(0, 10);
|
||||
const weight = Math.max(1, p.purchases || 0);
|
||||
const entry = byDay.get(day) ?? { sum: 0, weight: 0, volume: 0 };
|
||||
entry.sum += p.price_median * weight;
|
||||
entry.weight += weight;
|
||||
entry.volume += p.purchases || 0;
|
||||
byDay.set(day, entry);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, e]) => ({ day, cents: Math.round((e.sum / e.weight) * 100), volume: e.volume }))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000);
|
||||
}
|
||||
|
||||
async function curlPage(url: string) {
|
||||
const proc = Bun.spawn(
|
||||
['curl', '-sL', '--compressed', '--max-time', '60', '-A', ua, '-w', '\n%{http_code}', url],
|
||||
{ stdout: 'pipe', stderr: 'ignore' }
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const cut = out.lastIndexOf('\n');
|
||||
return { status: Number(out.slice(cut + 1)) || 0, body: out.slice(0, cut) };
|
||||
}
|
||||
|
||||
const kindOrder = [
|
||||
'knife',
|
||||
'gloves',
|
||||
'weapon',
|
||||
'container',
|
||||
'agent',
|
||||
'key',
|
||||
'music-kit',
|
||||
'patch',
|
||||
'collectible',
|
||||
'sticker',
|
||||
'graffiti',
|
||||
'charm',
|
||||
'other'
|
||||
];
|
||||
|
||||
async function steam(max: number) {
|
||||
const currencies: Record<number, string> = { 1: 'USD', 3: 'EUR' };
|
||||
const ids: Record<string, number> = {};
|
||||
const idFor = async (currency: string) =>
|
||||
(ids[currency] ??= await streamId(
|
||||
streams.steam.source,
|
||||
currency,
|
||||
streams.steam.kind,
|
||||
streams.steam.label
|
||||
));
|
||||
await idFor('EUR');
|
||||
// One representative unfetched phase-less config per skin, popular kinds/skins first.
|
||||
const queue = await sql<{ skin_id: string; item_id: number; hash: string }[]>`
|
||||
WITH todo AS (
|
||||
SELECT i.id, i.skin_id, i.market_hash_name, s.kind,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w
|
||||
WHERE w.item_id = i.id AND w.source = 'skinport' AND w.span = '90d'), 0) AS vol
|
||||
FROM cs.items i JOIN cs.skins s ON s.id = i.skin_id
|
||||
WHERE i.phase IS NULL AND NOT EXISTS (SELECT 1 FROM cs.history_fetches f
|
||||
WHERE f.item_id = i.id AND f.source = 'steam')
|
||||
), skins AS (
|
||||
SELECT skin_id, kind, sum(vol) AS vol FROM todo GROUP BY 1, 2
|
||||
)
|
||||
SELECT DISTINCT ON (s.vol, s.skin_id) s.skin_id, t.id AS item_id, t.market_hash_name AS hash
|
||||
FROM skins s JOIN todo t ON t.skin_id = s.skin_id
|
||||
ORDER BY s.vol DESC, s.skin_id, t.vol DESC, t.id`;
|
||||
const kinds = new Map(
|
||||
(await sql<{ id: string; kind: string }[]>`SELECT id, kind FROM cs.skins`).map((r) => [
|
||||
r.id,
|
||||
r.kind
|
||||
])
|
||||
);
|
||||
const rank = (skin: string) => {
|
||||
const index = kindOrder.indexOf(kinds.get(skin) ?? 'other');
|
||||
return index < 0 ? kindOrder.length : index;
|
||||
};
|
||||
const ordered = queue
|
||||
.map((row, index) => ({ ...row, index }))
|
||||
.sort((a, b) => rank(a.skin_id) - rank(b.skin_id) || a.index - b.index);
|
||||
log(`Steam: ${ordered.length} skins with unfetched configs`);
|
||||
let delay = 9000;
|
||||
let strikes = 0;
|
||||
let requests = 0;
|
||||
let stored = 0;
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const row = ordered[i];
|
||||
if (requests >= max) break;
|
||||
// Skip when an earlier page already covered this skin's representative.
|
||||
const [done] =
|
||||
await sql`SELECT 1 FROM cs.history_fetches WHERE item_id = ${row.item_id} AND source = 'steam'`;
|
||||
if (done) continue;
|
||||
const siblings = await sql<{ id: number; hash: string }[]>`
|
||||
SELECT id, market_hash_name AS hash FROM cs.items WHERE skin_id = ${row.skin_id} AND phase IS NULL`;
|
||||
const byHash = new Map(siblings.map((s) => [s.hash, s.id]));
|
||||
const url = `https://steamcommunity.com/market/listings/730/${encodeURIComponent(row.hash)}`;
|
||||
requests++;
|
||||
// curl with a bare UA and no Accept-Language: Steam answers Bun's fetch, and any request
|
||||
// with Accept-Language, with 429 but serves this normally.
|
||||
let page: { status: number; body: string };
|
||||
try {
|
||||
page = await curlPage(url);
|
||||
} catch (cause) {
|
||||
log(`net error ${row.hash}: ${String(cause)}`);
|
||||
await sleep(60_000);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
if (page.status === 429 || page.status >= 500 || page.status === 0) {
|
||||
strikes++;
|
||||
delay = Math.min(30_000, delay + 1000);
|
||||
const pause = Math.min(30 * 60_000, 60_000 * strikes);
|
||||
log(
|
||||
`HTTP ${page.status} (strike ${strikes}), pausing ${pause / 1000}s, delay now ${delay / 1000}s`
|
||||
);
|
||||
await sleep(pause);
|
||||
i--; // retry the same skin
|
||||
continue;
|
||||
}
|
||||
strikes = 0;
|
||||
if (page.status !== 200) {
|
||||
await mark(row.item_id, 'steam', [], `HTTP ${page.status}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
const histories = pageHistories(page.body);
|
||||
if (!histories) {
|
||||
await mark(row.item_id, 'steam', [], 'no ssr data');
|
||||
log(`no SSR data for ${row.hash}`);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
let pageRows = 0;
|
||||
for (const [hash, history] of histories) {
|
||||
const item =
|
||||
byHash.get(hash) ??
|
||||
(
|
||||
await sql<{ id: number }[]>`SELECT id FROM cs.items WHERE market_hash_name = ${hash}
|
||||
AND phase IS NULL`
|
||||
)[0]?.id;
|
||||
if (!item) continue;
|
||||
const currency = currencies[history.currency];
|
||||
if (!currency) {
|
||||
await mark(item, 'steam', [], `currency ${history.currency}`);
|
||||
continue;
|
||||
}
|
||||
const days = daily(history.prices);
|
||||
if (days.length) await store(await idFor(currency), item, days);
|
||||
await mark(item, 'steam', days);
|
||||
pageRows += days.length;
|
||||
}
|
||||
if (!histories.has(row.hash)) await mark(row.item_id, 'steam', [], 'not on page');
|
||||
stored += pageRows;
|
||||
if (requests % 25 === 0 || requests <= 3)
|
||||
log(
|
||||
`#${requests} ${row.hash}: ${histories.size} configs, ${pageRows} days (total ${stored})`
|
||||
);
|
||||
await sleep(delay + Math.random() * 3000);
|
||||
}
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${stored}
|
||||
WHERE source = ${streams.steam.source} AND kind = ${streams.steam.kind}`;
|
||||
log(`Steam done: ${requests} requests, ${stored} day rows`);
|
||||
}
|
||||
|
||||
/** Targets for the CSFloat fetcher: phase-less configs plus Doppler phases (paint index added by
|
||||
* the fetcher), most traded first, skipping ones already fetched. */
|
||||
async function csfloatQueue() {
|
||||
const rows = await sql<{ item_id: number; hash: string; phase: string | null; vol: number }[]>`
|
||||
SELECT i.id AS item_id, i.market_hash_name AS hash, i.phase,
|
||||
coalesce((SELECT max(w.volume) FROM cs.price_windows w WHERE w.item_id = i.id
|
||||
AND w.span = '90d'), 0)::int AS vol
|
||||
FROM cs.items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cs.history_fetches f WHERE f.item_id = i.id AND f.source = 'csfloat')
|
||||
ORDER BY vol DESC, i.id`;
|
||||
for (const row of rows) process.stdout.write(JSON.stringify(row) + '\n');
|
||||
}
|
||||
|
||||
async function csfloatImport() {
|
||||
const stream = await streamId(
|
||||
streams.csfloat.source,
|
||||
'USD',
|
||||
streams.csfloat.kind,
|
||||
streams.csfloat.label
|
||||
);
|
||||
let items = 0;
|
||||
let rows = 0;
|
||||
let buffer = '';
|
||||
const handle = async (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
const entry = JSON.parse(line) as {
|
||||
item_id: number;
|
||||
error?: string;
|
||||
graph?: { day: string; avg_price: number; count: number }[];
|
||||
};
|
||||
const days = (entry.graph ?? [])
|
||||
.map((g) => ({
|
||||
day: g.day.slice(0, 10),
|
||||
cents: Math.round(g.avg_price),
|
||||
volume: g.count ?? null
|
||||
}))
|
||||
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000)
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
if (days.length) await store(stream, entry.item_id, days);
|
||||
await mark(entry.item_id, 'csfloat', days, entry.error ?? null);
|
||||
items++;
|
||||
rows += days.length;
|
||||
};
|
||||
for await (const chunk of Bun.stdin.stream()) {
|
||||
buffer += new TextDecoder().decode(chunk);
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) await handle(line);
|
||||
}
|
||||
await handle(buffer);
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${rows} WHERE id = ${stream}`;
|
||||
log(`CSFloat import: ${items} configs, ${rows} day rows`);
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const rows = await sql`
|
||||
SELECT st.source, st.kind, st.currency, count(*)::int AS rows, count(DISTINCT dp.item_id)::int AS configs,
|
||||
min(dp.day)::text AS first_day, max(dp.day)::text AS last_day
|
||||
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||
WHERE st.kind LIKE '%-history' GROUP BY 1, 2, 3 ORDER BY 1`;
|
||||
console.table(rows);
|
||||
const [cov] = await sql`
|
||||
WITH h AS (SELECT dp.item_id, count(DISTINCT dp.day) AS days FROM cs.daily_prices dp
|
||||
JOIN cs.price_streams st ON st.id = dp.stream_id WHERE st.kind LIKE '%-history' GROUP BY 1)
|
||||
SELECT (SELECT count(*) FROM cs.items)::int AS configs, count(*)::int AS with_history,
|
||||
count(*) FILTER (WHERE days > 30)::int AS over_30_days FROM h`;
|
||||
console.log(cov);
|
||||
console.table(
|
||||
await sql`SELECT source, count(*)::int AS fetched, sum(points)::int AS points,
|
||||
count(*) FILTER (WHERE points > 0)::int AS with_points, max(fetched_at) AS last
|
||||
FROM cs.history_fetches GROUP BY 1`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'fx') await fx();
|
||||
else if (mode === 'steam') await steam(Number(process.argv[3] ?? Infinity));
|
||||
else if (mode === 'csfloat-queue') await csfloatQueue();
|
||||
else if (mode === 'csfloat-import') await csfloatImport();
|
||||
else if (mode === 'status') await status();
|
||||
else throw new Error('Usage: cs-history.ts fx|steam [max]|csfloat-queue|csfloat-import|status');
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
624
scripts/cs-prices.ts
Normal file
624
scripts/cs-prices.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* CS2 catalog + daily prices (all configs) into cs.*. Runs inside the app container:
|
||||
*
|
||||
* bun scripts/cs-prices.ts daily catalog if older than 6 days, then today's prices
|
||||
* bun scripts/cs-prices.ts catalog refresh the full catalog from ByMykel/CSGO-API
|
||||
* bun scripts/cs-prices.ts prices today's prices from every bulk source
|
||||
*
|
||||
* One row per config per stream per UTC day (cs.daily_prices, idempotent upsert), so a rerun on
|
||||
* the same day just refreshes that day. Bulk endpoints only, no per-item Steam scraping:
|
||||
* - Skinport /v1/items (EUR, lowest listing + quantity, Doppler phases via `version`)
|
||||
* - Skinport /v1/sales/history (EUR, 24h/7d/30d/90d sale aggregates)
|
||||
* - prices.csgotrader.app (USD): Steam Market average sales (24h, else 7d), CSFloat and
|
||||
* Buff163 lowest listings (with Doppler phases)
|
||||
* USD→EUR comes from the ECB rate (Frankfurter) and is stored per day in cs.fx_rates.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
import { dimensionsFromName } from '../src/lib/cs-prices/taxonomy';
|
||||
import { parseConfig, slugify } from '../src/lib/cs-prices/catalog';
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
|
||||
const mode = process.argv[2] ?? 'daily';
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const ua = { 'User-Agent': 'stanrunge.dev cs-prices/2.0 (daily personal price archive)' };
|
||||
const bymykel = 'https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en';
|
||||
|
||||
type Skin = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
weapon: string | null;
|
||||
pattern: string | null;
|
||||
category: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
image: string | null;
|
||||
min_float: number | null;
|
||||
max_float: number | null;
|
||||
collections: string[] | null;
|
||||
source: string;
|
||||
};
|
||||
type Item = {
|
||||
market_hash_name: string;
|
||||
phase: string | null;
|
||||
skin_id: string;
|
||||
name: string;
|
||||
icon_url: string | null;
|
||||
item_type: string | null;
|
||||
wear: string | null;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
};
|
||||
|
||||
async function getJson<T>(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
tries = 4
|
||||
): Promise<T> {
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < tries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { ...ua, ...headers },
|
||||
signal: AbortSignal.timeout(180_000)
|
||||
});
|
||||
if (response.ok) return (await response.json()) as T;
|
||||
last = new Error(`HTTP ${response.status} for ${new URL(url).host}${new URL(url).pathname}`);
|
||||
if (response.status !== 429 && response.status < 500) break;
|
||||
} catch (cause) {
|
||||
last = cause;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000 * 2 ** attempt));
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
const configKey = (hash: string, phase: string | null) => `${hash}\u0000${phase ?? ''}`;
|
||||
const cents = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 21_000_000
|
||||
? Math.round(value * 100)
|
||||
: null;
|
||||
|
||||
function kindFor(file: string, category?: string): string {
|
||||
if (file === 'skins_not_grouped') {
|
||||
if (category === 'Knives') return 'knife';
|
||||
if (category === 'Gloves') return 'gloves';
|
||||
return 'weapon';
|
||||
}
|
||||
return (
|
||||
{
|
||||
stickers: 'sticker',
|
||||
crates: 'container',
|
||||
agents: 'agent',
|
||||
keychains: 'charm',
|
||||
collectibles: 'collectible',
|
||||
graffiti: 'graffiti',
|
||||
patches: 'patch',
|
||||
music_kits: 'music-kit',
|
||||
keys: 'key'
|
||||
}[file] ?? 'other'
|
||||
);
|
||||
}
|
||||
|
||||
/** Feed-only names (e.g. sticker slabs, highlight charms) still get a skin page. */
|
||||
function kindFromName(hash: string) {
|
||||
const { base, starred } = parseConfig(hash);
|
||||
const head = base.split(' | ')[0];
|
||||
if (starred) return /Gloves|Wraps/.test(head) ? 'gloves' : 'knife';
|
||||
if (/^Sticker Slab$|^Charm$|Highlight/.test(head) || /^Souvenir Charm/.test(hash)) return 'charm';
|
||||
if (/^Sticker$/.test(head)) return 'sticker';
|
||||
if (/^(Sealed )?Graffiti$/.test(head)) return 'graffiti';
|
||||
if (/^Patch$/.test(head)) return 'patch';
|
||||
if (/^Music Kit$/.test(head)) return 'music-kit';
|
||||
if (/Case|Capsule|Package|Container|Collection/.test(base)) return 'container';
|
||||
if (dimensionsFromName(hash).category === 'weapon') return 'weapon';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
async function upsertSkins(skins: Skin[]) {
|
||||
for (let offset = 0; offset < skins.length; offset += 2000) {
|
||||
const chunk = skins.slice(offset, offset + 2000);
|
||||
await sql`
|
||||
INSERT INTO cs.skins (id, slug, name, kind, weapon, pattern, category, rarity, rarity_color,
|
||||
image, min_float, max_float, collections, source, updated_at)
|
||||
SELECT id, slug, name, kind, weapon, pattern, category, rarity, rarity_color, image,
|
||||
min_float, max_float, collections, source, now()
|
||||
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(id text, slug text, name text,
|
||||
kind text, weapon text, pattern text, category text, rarity text, rarity_color text,
|
||||
image text, min_float real, max_float real, collections text[], source text)
|
||||
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, kind = EXCLUDED.kind,
|
||||
weapon = coalesce(EXCLUDED.weapon, cs.skins.weapon),
|
||||
pattern = coalesce(EXCLUDED.pattern, cs.skins.pattern),
|
||||
category = coalesce(EXCLUDED.category, cs.skins.category),
|
||||
rarity = coalesce(EXCLUDED.rarity, cs.skins.rarity),
|
||||
rarity_color = coalesce(EXCLUDED.rarity_color, cs.skins.rarity_color),
|
||||
image = coalesce(EXCLUDED.image, cs.skins.image),
|
||||
min_float = coalesce(EXCLUDED.min_float, cs.skins.min_float),
|
||||
max_float = coalesce(EXCLUDED.max_float, cs.skins.max_float),
|
||||
collections = coalesce(EXCLUDED.collections, cs.skins.collections),
|
||||
source = CASE WHEN EXCLUDED.source = 'bymykel' THEN 'bymykel' ELSE cs.skins.source END,
|
||||
updated_at = CASE WHEN EXCLUDED.source = 'bymykel' THEN now() ELSE cs.skins.updated_at END`;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertItems(items: Item[]) {
|
||||
const ids = new Map<string, number>();
|
||||
for (let offset = 0; offset < items.length; offset += 2000) {
|
||||
const chunk = items.slice(offset, offset + 2000);
|
||||
const rows = await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
||||
INSERT INTO cs.items (market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
||||
stattrak, souvenir)
|
||||
SELECT market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
||||
coalesce(stattrak, false), coalesce(souvenir, false)
|
||||
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(market_hash_name text,
|
||||
phase text, skin_id text, name text, icon_url text, item_type text, wear text,
|
||||
stattrak boolean, souvenir boolean)
|
||||
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
|
||||
skin_id = EXCLUDED.skin_id,
|
||||
name = EXCLUDED.name,
|
||||
icon_url = coalesce(EXCLUDED.icon_url, cs.items.icon_url),
|
||||
item_type = coalesce(EXCLUDED.item_type, cs.items.item_type),
|
||||
wear = EXCLUDED.wear, stattrak = EXCLUDED.stattrak, souvenir = EXCLUDED.souvenir,
|
||||
last_seen_at = now()
|
||||
RETURNING id, market_hash_name, phase`;
|
||||
for (const row of rows) ids.set(configKey(row.market_hash_name, row.phase), row.id);
|
||||
// Facets for the /projects/cs-prices compare explorer.
|
||||
const dims: { item_id: number; key: string; value: string }[] = [];
|
||||
const byKey = new Map(
|
||||
chunk.map((item) => [configKey(item.market_hash_name, item.phase), item])
|
||||
);
|
||||
for (const row of rows) {
|
||||
const item = byKey.get(configKey(row.market_hash_name, row.phase));
|
||||
const facets = dimensionsFromName(row.market_hash_name);
|
||||
if (row.phase) facets.phase = row.phase;
|
||||
if (item?.item_type) facets.steam_type = item.item_type;
|
||||
for (const [key, value] of Object.entries(facets))
|
||||
if (value) dims.push({ item_id: row.id, key, value });
|
||||
}
|
||||
if (dims.length)
|
||||
await sql`
|
||||
INSERT INTO cs.item_dimensions (item_id, key, value)
|
||||
SELECT item_id, key, value FROM jsonb_to_recordset(${sql.json(dims as never)}::jsonb)
|
||||
AS x(item_id int, key text, value text)
|
||||
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
type Raw = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
market_hash_name?: string | null;
|
||||
image?: string | null;
|
||||
phase?: string | null;
|
||||
skin_id?: string;
|
||||
weapon?: { name?: string } | null;
|
||||
pattern?: { name?: string } | null;
|
||||
category?: { name?: string } | null;
|
||||
rarity?: { name?: string; color?: string } | null;
|
||||
min_float?: number | null;
|
||||
max_float?: number | null;
|
||||
collections?: { name?: string }[] | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
async function catalog() {
|
||||
const files = [
|
||||
'skins_not_grouped',
|
||||
'stickers',
|
||||
'crates',
|
||||
'agents',
|
||||
'keychains',
|
||||
'collectibles',
|
||||
'graffiti',
|
||||
'patches',
|
||||
'music_kits',
|
||||
'keys'
|
||||
];
|
||||
const skins = new Map<string, Skin>();
|
||||
const items = new Map<string, Item>();
|
||||
const slugOwner = new Map<string, string>();
|
||||
const existing = await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`;
|
||||
for (const row of existing) slugOwner.set(row.slug, row.id);
|
||||
const skinFor = (base: string) => {
|
||||
// The grouping key is the base name; the id stays stable once assigned.
|
||||
const slug = slugify(base);
|
||||
return slugOwner.get(slug) ?? slug;
|
||||
};
|
||||
for (const file of files) {
|
||||
const rows = await getJson<Raw[]>(`${bymykel}/${file}.json`);
|
||||
console.log(`ByMykel ${file}: ${rows.length}`);
|
||||
for (const raw of rows) {
|
||||
if (!raw.market_hash_name) continue;
|
||||
const config = parseConfig(raw.market_hash_name);
|
||||
const kind = kindFor(file, raw.category?.name);
|
||||
const id = skinFor(config.base);
|
||||
slugOwner.set(slugify(config.base), id);
|
||||
const current = skins.get(id);
|
||||
const preferImage = !raw.phase || raw.phase === 'Phase 2';
|
||||
if (!current)
|
||||
skins.set(id, {
|
||||
id,
|
||||
slug: slugify(config.base),
|
||||
name: config.display,
|
||||
kind,
|
||||
weapon: raw.weapon?.name ?? null,
|
||||
pattern: raw.pattern?.name ?? null,
|
||||
category: raw.category?.name ?? raw.type ?? null,
|
||||
rarity: raw.rarity?.name ?? null,
|
||||
rarity_color: raw.rarity?.color ?? null,
|
||||
image: preferImage ? (raw.image ?? null) : null,
|
||||
min_float: raw.min_float ?? null,
|
||||
max_float: raw.max_float ?? null,
|
||||
collections: raw.collections?.map((c) => c.name ?? '').filter(Boolean) ?? null,
|
||||
source: 'bymykel'
|
||||
});
|
||||
else if (!current.image && preferImage && raw.image) current.image = raw.image;
|
||||
const add = (phase: string | null) =>
|
||||
items.set(configKey(raw.market_hash_name!, phase), {
|
||||
market_hash_name: raw.market_hash_name!,
|
||||
phase,
|
||||
skin_id: id,
|
||||
name: phase ? `${raw.market_hash_name} - ${phase}` : raw.market_hash_name!,
|
||||
icon_url: raw.image ?? null,
|
||||
item_type: raw.category?.name ?? raw.type ?? kind,
|
||||
wear: config.wear,
|
||||
stattrak: config.stattrak,
|
||||
souvenir: config.souvenir
|
||||
});
|
||||
add(raw.phase ?? null);
|
||||
// The phase-less listing carries Steam's and other phase-blind prices.
|
||||
if (raw.phase && !items.has(configKey(raw.market_hash_name, null))) add(null);
|
||||
}
|
||||
}
|
||||
const itemList = [...items.values()];
|
||||
for (const skin of skins.values())
|
||||
if (!skin.image)
|
||||
skin.image =
|
||||
itemList.find((item) => item.skin_id === skin.id && item.icon_url)?.icon_url ?? null;
|
||||
// Phase-less rows of Doppler listings should not show one phase's image.
|
||||
for (const item of items.values())
|
||||
if (!item.phase && /Doppler/.test(item.name)) item.icon_url = null;
|
||||
await upsertSkins([...skins.values()]);
|
||||
const ids = await upsertItems([...items.values()]);
|
||||
// Older Steam-only rows: link them to a skin by name.
|
||||
await sql`UPDATE cs.items i SET skin_id = s.id FROM cs.skins s
|
||||
WHERE i.skin_id IS NULL AND s.slug = regexp_replace(regexp_replace(lower(regexp_replace(
|
||||
regexp_replace(regexp_replace(i.market_hash_name, '^★ ', ''), '^(StatTrak™ |Souvenir )', ''),
|
||||
' \\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\\)$', '')),
|
||||
'[^a-z0-9]+', '-', 'g'), '^-+|-+$', '', 'g')`;
|
||||
console.log(`Catalog: ${skins.size} skins, ${ids.size} configs`);
|
||||
}
|
||||
|
||||
type Row = { hash: string; phase: string | null; cents: number; volume: number | null };
|
||||
|
||||
async function ensureItems(rows: Row[]) {
|
||||
const known = new Map<string, number>();
|
||||
for (const row of await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
||||
SELECT id, market_hash_name, phase FROM cs.items`)
|
||||
known.set(configKey(row.market_hash_name, row.phase), row.id);
|
||||
const missing = new Map<string, Item>();
|
||||
const newSkins = new Map<string, Skin>();
|
||||
const slugs = new Map(
|
||||
(await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`).map((r) => [
|
||||
r.slug,
|
||||
r.id
|
||||
])
|
||||
);
|
||||
for (const row of rows) {
|
||||
const key = configKey(row.hash, row.phase);
|
||||
if (known.has(key) || missing.has(key)) continue;
|
||||
// Untranslated tokens and truncated names in the feeds are not real listings.
|
||||
if (/^#|\|\s*$|^\s*$/.test(row.hash) || row.hash.length > 200) continue;
|
||||
const config = parseConfig(row.hash);
|
||||
const slug = slugify(config.base);
|
||||
let skinId = slugs.get(slug);
|
||||
if (!skinId) {
|
||||
skinId = slug;
|
||||
slugs.set(slug, skinId);
|
||||
newSkins.set(skinId, {
|
||||
id: skinId,
|
||||
slug,
|
||||
name: config.display,
|
||||
kind: kindFromName(row.hash),
|
||||
weapon: null,
|
||||
pattern: null,
|
||||
category: null,
|
||||
rarity: null,
|
||||
rarity_color: null,
|
||||
image: null,
|
||||
min_float: null,
|
||||
max_float: null,
|
||||
collections: null,
|
||||
source: 'market'
|
||||
});
|
||||
}
|
||||
missing.set(key, {
|
||||
market_hash_name: row.hash,
|
||||
phase: row.phase,
|
||||
skin_id: skinId,
|
||||
name: row.phase ? `${row.hash} - ${row.phase}` : row.hash,
|
||||
icon_url: null,
|
||||
item_type: null,
|
||||
wear: config.wear,
|
||||
stattrak: config.stattrak,
|
||||
souvenir: config.souvenir
|
||||
});
|
||||
}
|
||||
if (newSkins.size) await upsertSkins([...newSkins.values()]);
|
||||
if (missing.size) {
|
||||
for (const [key, id] of await upsertItems([...missing.values()])) known.set(key, id);
|
||||
console.log(`New configs from price feeds: ${missing.size} (${newSkins.size} new skins)`);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
async function streamId(source: string, currency: string, kind: string) {
|
||||
const [row] = await sql<{ id: number }[]>`
|
||||
INSERT INTO cs.price_streams (source, currency, kind) VALUES (${source}, ${currency}, ${kind})
|
||||
ON CONFLICT (source, currency, kind) DO UPDATE SET source = EXCLUDED.source RETURNING id`;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function saveDaily(source: string, currency: string, kind: string, rows: Row[]) {
|
||||
const stream = await streamId(source, currency, kind);
|
||||
const ids = await ensureItems(rows);
|
||||
const dedup = new Map<number, Row>();
|
||||
for (const row of rows) {
|
||||
const id = ids.get(configKey(row.hash, row.phase));
|
||||
if (id) dedup.set(id, row);
|
||||
}
|
||||
const entries = [...dedup.entries()];
|
||||
for (let offset = 0; offset < entries.length; offset += 5000) {
|
||||
const chunk = entries.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||
SELECT item_id, ${stream}::smallint, ${day}::date, price_cents, volume
|
||||
FROM unnest(${chunk.map(([id]) => id)}::int[], ${chunk.map(([, r]) => r.cents)}::int[],
|
||||
${chunk.map(([, r]) => r.volume)}::int[]) AS x(item_id, price_cents, volume)
|
||||
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||
}
|
||||
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${entries.length},
|
||||
last_error = NULL WHERE id = ${stream}`;
|
||||
console.log(`${source} ${kind}: ${entries.length} configs for ${day}`);
|
||||
return ids;
|
||||
}
|
||||
|
||||
type Window = {
|
||||
item: number;
|
||||
span: string;
|
||||
median: number | null;
|
||||
avg: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
volume: number | null;
|
||||
};
|
||||
async function saveWindows(source: string, currency: string, windows: Window[]) {
|
||||
for (let offset = 0; offset < windows.length; offset += 5000) {
|
||||
const chunk = windows.slice(offset, offset + 5000);
|
||||
await sql`
|
||||
INSERT INTO cs.price_windows (item_id, source, currency, span, median_cents, avg_cents,
|
||||
min_cents, max_cents, volume, updated_at)
|
||||
SELECT item_id, ${source}, ${currency}, span, median_cents, avg_cents, min_cents, max_cents,
|
||||
volume, now()
|
||||
FROM unnest(${chunk.map((w) => w.item)}::int[], ${chunk.map((w) => w.span)}::text[],
|
||||
${chunk.map((w) => w.median)}::int[], ${chunk.map((w) => w.avg)}::int[],
|
||||
${chunk.map((w) => w.min)}::int[], ${chunk.map((w) => w.max)}::int[],
|
||||
${chunk.map((w) => w.volume)}::int[])
|
||||
AS x(item_id, span, median_cents, avg_cents, min_cents, max_cents, volume)
|
||||
ON CONFLICT (item_id, source, span) DO UPDATE SET median_cents = EXCLUDED.median_cents,
|
||||
avg_cents = EXCLUDED.avg_cents, min_cents = EXCLUDED.min_cents,
|
||||
max_cents = EXCLUDED.max_cents, volume = EXCLUDED.volume, updated_at = now()`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fx() {
|
||||
for (const url of [
|
||||
'https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR',
|
||||
'https://api.frankfurter.app/latest?from=USD&to=EUR'
|
||||
]) {
|
||||
try {
|
||||
const data = await getJson<{ rates?: { EUR?: number } }>(url, {}, 2);
|
||||
if (data.rates?.EUR) {
|
||||
await sql`INSERT INTO cs.fx_rates (day, usd_eur) VALUES (${day}, ${data.rates.EUR})
|
||||
ON CONFLICT (day) DO UPDATE SET usd_eur = EXCLUDED.usd_eur`;
|
||||
console.log(`FX USD→EUR ${data.rates.EUR}`);
|
||||
return;
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn(`FX ${new URL(url).host}: ${String(cause)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function guarded(
|
||||
source: string,
|
||||
currency: string,
|
||||
kinds: string[],
|
||||
run: () => Promise<void>
|
||||
) {
|
||||
try {
|
||||
await run();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
console.error(`${source} failed: ${String(cause)}`);
|
||||
for (const kind of kinds)
|
||||
await sql`UPDATE cs.price_streams SET last_error = ${String(cause).slice(0, 500)}
|
||||
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spans = { last_24_hours: '24h', last_7_days: '7d', last_30_days: '30d', last_90_days: '90d' };
|
||||
|
||||
async function prices() {
|
||||
await fx();
|
||||
const results: boolean[] = [];
|
||||
const br = { 'Accept-Encoding': 'br' };
|
||||
results.push(
|
||||
await guarded('skinport', 'EUR', ['lowest-listing'], async () => {
|
||||
const items = await getJson<
|
||||
{
|
||||
market_hash_name: string;
|
||||
version: string | null;
|
||||
min_price: number | null;
|
||||
quantity: number;
|
||||
}[]
|
||||
>('https://api.skinport.com/v1/items?app_id=730¤cy=EUR', br);
|
||||
await saveDaily(
|
||||
'skinport',
|
||||
'EUR',
|
||||
'lowest-listing',
|
||||
items.flatMap((item) => {
|
||||
const value = cents(item.min_price);
|
||||
return value == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
hash: item.market_hash_name,
|
||||
phase: item.version || null,
|
||||
cents: value,
|
||||
volume: item.quantity ?? null
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
results.push(
|
||||
await guarded('skinport', 'EUR', ['sale-median-24h'], async () => {
|
||||
type Agg = {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
median: number | null;
|
||||
volume: number;
|
||||
};
|
||||
const history = await getJson<
|
||||
(Record<string, Agg> & { market_hash_name: string; version: string | null })[]
|
||||
>('https://api.skinport.com/v1/sales/history?app_id=730¤cy=EUR', br);
|
||||
const rows: Row[] = [];
|
||||
for (const entry of history) {
|
||||
const day24 = entry.last_24_hours;
|
||||
const value = cents(day24?.median);
|
||||
if (value != null)
|
||||
rows.push({
|
||||
hash: entry.market_hash_name,
|
||||
phase: entry.version || null,
|
||||
cents: value,
|
||||
volume: day24.volume
|
||||
});
|
||||
}
|
||||
const ids = await saveDaily('skinport', 'EUR', 'sale-median-24h', rows);
|
||||
const windows: Window[] = [];
|
||||
const allIds = await ensureItems(
|
||||
history.map((e) => ({
|
||||
hash: e.market_hash_name,
|
||||
phase: e.version || null,
|
||||
cents: 0,
|
||||
volume: 0
|
||||
}))
|
||||
);
|
||||
for (const entry of history) {
|
||||
const item = (ids.get(configKey(entry.market_hash_name, entry.version || null)) ??
|
||||
allIds.get(configKey(entry.market_hash_name, entry.version || null)))!;
|
||||
if (!item) continue;
|
||||
for (const [field, span] of Object.entries(spans)) {
|
||||
const agg = entry[field];
|
||||
if (!agg || !agg.volume) continue;
|
||||
windows.push({
|
||||
item,
|
||||
span,
|
||||
median: cents(agg.median),
|
||||
avg: cents(agg.avg),
|
||||
min: cents(agg.min),
|
||||
max: cents(agg.max),
|
||||
volume: agg.volume
|
||||
});
|
||||
}
|
||||
}
|
||||
await saveWindows('skinport', 'EUR', windows);
|
||||
console.log(`skinport windows: ${windows.length}`);
|
||||
})
|
||||
);
|
||||
results.push(
|
||||
await guarded('steam-market', 'USD', ['sale-avg'], async () => {
|
||||
const steam = await getJson<Record<string, Record<string, number | null>>>(
|
||||
'https://prices.csgotrader.app/latest/steam.json'
|
||||
);
|
||||
const rows: Row[] = [];
|
||||
for (const [hash, value] of Object.entries(steam)) {
|
||||
// Latest average sale price: the last 24 hours, else the last 7 days.
|
||||
const price = cents(value?.last_24h ?? value?.last_7d);
|
||||
if (price != null) rows.push({ hash, phase: null, cents: price, volume: null });
|
||||
}
|
||||
const ids = await saveDaily('steam-market', 'USD', 'sale-avg', rows);
|
||||
const all = await ensureItems(
|
||||
Object.keys(steam).map((hash) => ({ hash, phase: null, cents: 0, volume: 0 }))
|
||||
);
|
||||
const windows: Window[] = [];
|
||||
for (const [hash, value] of Object.entries(steam)) {
|
||||
const item = ids.get(configKey(hash, null)) ?? all.get(configKey(hash, null));
|
||||
if (!item || !value) continue;
|
||||
for (const span of ['24h', '7d', '30d', '90d']) {
|
||||
const avg = cents(value[`last_${span}`]);
|
||||
if (avg != null)
|
||||
windows.push({ item, span, median: null, avg, min: null, max: null, volume: null });
|
||||
}
|
||||
}
|
||||
await saveWindows('steam-market', 'USD', windows);
|
||||
})
|
||||
);
|
||||
for (const [source, file] of [
|
||||
['csfloat', 'csfloat'],
|
||||
['buff163', 'buff163']
|
||||
] as const) {
|
||||
results.push(
|
||||
await guarded(source, 'USD', ['lowest-listing'], async () => {
|
||||
type Entry = {
|
||||
price?: number;
|
||||
starting_at?: { price?: number | null; doppler?: Record<string, number> } | null;
|
||||
doppler?: Record<string, number>;
|
||||
};
|
||||
const feed = await getJson<Record<string, Entry>>(
|
||||
`https://prices.csgotrader.app/latest/${file}.json`
|
||||
);
|
||||
const rows: Row[] = [];
|
||||
for (const [hash, entry] of Object.entries(feed)) {
|
||||
const base = cents(entry?.starting_at?.price ?? entry?.price);
|
||||
if (base != null) rows.push({ hash, phase: null, cents: base, volume: null });
|
||||
const phases = entry?.starting_at?.doppler ?? entry?.doppler;
|
||||
for (const [phase, value] of Object.entries(phases ?? {})) {
|
||||
const price = cents(value);
|
||||
if (price != null) rows.push({ hash, phase, cents: price, volume: null });
|
||||
}
|
||||
}
|
||||
await saveDaily(source, 'USD', 'lowest-listing', rows);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!results.some(Boolean)) throw new Error('Every price source failed');
|
||||
const [summary] = await sql<{ rows: number; items: number }[]>`
|
||||
SELECT count(*)::int AS rows, count(DISTINCT item_id)::int AS items
|
||||
FROM cs.daily_prices WHERE day = ${day}`;
|
||||
console.log(`Today (${day}): ${summary.rows} price rows across ${summary.items} configs`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'catalog') await catalog();
|
||||
else if (mode === 'prices') await prices();
|
||||
else if (mode === 'daily') {
|
||||
const [state] = await sql<{ fresh: boolean }[]>`
|
||||
SELECT coalesce(max(updated_at) > now() - interval '6 days', false) AS fresh
|
||||
FROM cs.skins WHERE source = 'bymykel'`;
|
||||
if (!state.fresh) await catalog();
|
||||
await prices();
|
||||
} else throw new Error('Usage: cs-prices.ts daily|catalog|prices');
|
||||
await sql`ANALYZE cs.items`;
|
||||
await sql`ANALYZE cs.daily_prices`;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -188,8 +188,9 @@ try {
|
||||
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
|
||||
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
|
||||
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
|
||||
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
|
||||
const AI_TABLES = 3;
|
||||
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
|
||||
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
|
||||
const AI_TABLES = 5;
|
||||
const [ai] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
@@ -286,6 +287,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
|
||||
|
||||
@@ -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
178
scripts/radio-banks-1-3.ts
Normal 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
275
scripts/radio.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
498
scripts/stats/jobs.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { ECharts } from 'echarts/core';
|
||||
|
||||
export type PriceSeries = { key: string; name: string; points: [number, number][] };
|
||||
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
|
||||
$props();
|
||||
export type PriceSeries = {
|
||||
key: string;
|
||||
name: string;
|
||||
points: [number, number][];
|
||||
color?: string;
|
||||
dashed?: boolean;
|
||||
};
|
||||
let {
|
||||
series,
|
||||
kind,
|
||||
currency,
|
||||
height = 'min(62vh, 560px)',
|
||||
zoom = true
|
||||
}: {
|
||||
series: PriceSeries[];
|
||||
kind: string;
|
||||
currency: string;
|
||||
height?: string;
|
||||
zoom?: boolean;
|
||||
} = $props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
|
||||
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
@@ -22,15 +39,53 @@
|
||||
const foreground = style.getPropertyValue('--site-text').trim();
|
||||
const muted = style.getPropertyValue('--site-muted').trim();
|
||||
const border = style.getPropertyValue('--site-border').trim();
|
||||
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
|
||||
const many = series.length > 100;
|
||||
const legend = series.length > 1 && series.length <= 12;
|
||||
const surface = style.getPropertyValue('--site-surface').trim();
|
||||
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
|
||||
let first = Infinity;
|
||||
let last = -Infinity;
|
||||
for (const line of series)
|
||||
for (const [t] of line.points) {
|
||||
if (t < first) first = t;
|
||||
if (t > last) last = t;
|
||||
}
|
||||
const day = 86_400_000;
|
||||
const short = Number.isFinite(first) && last - first < 7 * day;
|
||||
const pad = short ? (7 * day - (last - first)) / 2 : 0;
|
||||
chart.setOption(
|
||||
{
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 78, right: 20, top: 28, bottom: 72 },
|
||||
grid: {
|
||||
left: 64,
|
||||
right: 14,
|
||||
top: legend ? 40 : 20,
|
||||
bottom: zoom ? 64 : 28,
|
||||
containLabel: false
|
||||
},
|
||||
legend: legend
|
||||
? {
|
||||
top: 0,
|
||||
left: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 4,
|
||||
textStyle: { color: muted, fontSize: 11 }
|
||||
}
|
||||
: { show: false },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: short ? first - pad : undefined,
|
||||
max: short ? last + pad : undefined,
|
||||
minInterval: day,
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted },
|
||||
axisLabel: {
|
||||
color: muted,
|
||||
hideOverlap: true,
|
||||
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
|
||||
},
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
@@ -39,44 +94,71 @@
|
||||
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
|
||||
splitLine: { lineStyle: { color: border } }
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 12,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
],
|
||||
tooltip:
|
||||
series.length > 100
|
||||
? { show: false }
|
||||
dataZoom: zoom
|
||||
? [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
tooltip: many
|
||||
? { show: false }
|
||||
: legend || series.length === 1
|
||||
? {
|
||||
trigger: 'axis',
|
||||
backgroundColor: surface,
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground, fontSize: 12 },
|
||||
formatter: (
|
||||
params: {
|
||||
seriesName: string;
|
||||
value: [number, number];
|
||||
color: string;
|
||||
}[]
|
||||
) =>
|
||||
params.length
|
||||
? `${new Date(params[0].value[0]).toLocaleDateString('nl-NL')}<br>` +
|
||||
params
|
||||
.map(
|
||||
(p) =>
|
||||
`<span style="color:${p.color}">●</span> ${escapeHtml(p.seriesName)} · <b>${money.format(p.value[1] / 100)}</b>`
|
||||
)
|
||||
.join('<br>')
|
||||
: ''
|
||||
}
|
||||
: {
|
||||
trigger: 'item',
|
||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
||||
backgroundColor: surface,
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground },
|
||||
formatter: (params: { seriesName: string; value: [number, number] }) =>
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString('nl-NL')} · ${money.format(params.value[1] / 100)}`
|
||||
},
|
||||
series: series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.points,
|
||||
showSymbol: false,
|
||||
// Few points (a new archive, or one sweep) would be invisible as a bare line.
|
||||
showSymbol: item.points.length <= 60,
|
||||
symbolSize: item.points.length <= 3 ? 7 : 4,
|
||||
connectNulls: false,
|
||||
silent: series.length > 100,
|
||||
silent: many,
|
||||
lineStyle: {
|
||||
width: series.length > 100 ? 0.7 : 1.8,
|
||||
opacity: series.length > 100 ? 0.42 : 0.9
|
||||
width: many ? 0.7 : 1.8,
|
||||
opacity: many ? 0.42 : 0.9,
|
||||
type: item.dashed ? 'dashed' : 'solid'
|
||||
},
|
||||
itemStyle: {
|
||||
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
|
||||
color: item.color ?? `hsl(${(index * 137.508) % 360} 65% ${dark ? '68%' : '38%'})`
|
||||
},
|
||||
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
|
||||
large: true
|
||||
large: many
|
||||
}))
|
||||
},
|
||||
true
|
||||
@@ -90,7 +172,7 @@
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
@@ -98,7 +180,14 @@
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
|
||||
echarts.use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
LegendComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||
ready = true;
|
||||
update();
|
||||
@@ -107,7 +196,7 @@
|
||||
theme = new MutationObserver(update);
|
||||
theme.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
attributeFilter: ['data-theme', 'class']
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -118,12 +207,16 @@
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
// Track the props so a new selection redraws.
|
||||
void series;
|
||||
void currency;
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="chart"
|
||||
style:height
|
||||
bind:this={element}
|
||||
role="img"
|
||||
aria-label={`${series.length} ${kind} price series chart`}
|
||||
@@ -132,7 +225,6 @@
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: min(62vh, 560px);
|
||||
min-height: 320px;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/lib/cs-prices/catalog.test.ts
Normal file
43
src/lib/cs-prices/catalog.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { configRank, parseConfig, slugify } from './catalog';
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('splits knives, StatTrak and wear', () => {
|
||||
expect(parseConfig('★ StatTrak™ Karambit | Doppler (Factory New)')).toEqual({
|
||||
starred: true,
|
||||
stattrak: true,
|
||||
souvenir: false,
|
||||
wear: 'Factory New',
|
||||
base: 'Karambit | Doppler',
|
||||
display: '★ Karambit | Doppler'
|
||||
});
|
||||
});
|
||||
it('handles souvenirs and wearless items', () => {
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').souvenir).toBe(true);
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').base).toBe('AWP | Dragon Lore');
|
||||
expect(parseConfig('Sticker | Heroic (Holo) | Rio 2022').base).toBe(
|
||||
'Sticker | Heroic (Holo) | Rio 2022'
|
||||
);
|
||||
expect(parseConfig('Paris 2023 Mirage Souvenir Package').souvenir).toBe(false);
|
||||
expect(parseConfig('StatTrak™ Music Kit | Daniel Sadowski, Crimson Assault').base).toBe(
|
||||
'Music Kit | Daniel Sadowski, Crimson Assault'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('makes stable slugs', () => {
|
||||
expect(slugify('★ Karambit | Doppler')).toBe('karambit-doppler');
|
||||
expect(slugify('AK-47 | Redline')).toBe('ak-47-redline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configRank', () => {
|
||||
it('sorts normal, then StatTrak, by wear and phase', () => {
|
||||
const a = configRank({ stattrak: false, souvenir: false, wear: 'Factory New', phase: 'Ruby' });
|
||||
const b = configRank({ stattrak: false, souvenir: false, wear: 'Minimal Wear', phase: null });
|
||||
const c = configRank({ stattrak: true, souvenir: false, wear: 'Factory New', phase: null });
|
||||
expect(a).toBeLessThan(b);
|
||||
expect(b).toBeLessThan(c);
|
||||
});
|
||||
});
|
||||
87
src/lib/cs-prices/catalog.ts
Normal file
87
src/lib/cs-prices/catalog.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure helpers shared by scripts/cs-prices.ts and the item pages: how a market hash name maps to
|
||||
// its skin (the page that groups every wear, StatTrak, Souvenir and phase config).
|
||||
|
||||
export const wearOrder = [
|
||||
'Factory New',
|
||||
'Minimal Wear',
|
||||
'Field-Tested',
|
||||
'Well-Worn',
|
||||
'Battle-Scarred'
|
||||
] as const;
|
||||
export const wearShort: Record<string, string> = {
|
||||
'Factory New': 'FN',
|
||||
'Minimal Wear': 'MW',
|
||||
'Field-Tested': 'FT',
|
||||
'Well-Worn': 'WW',
|
||||
'Battle-Scarred': 'BS'
|
||||
};
|
||||
export const phaseOrder = [
|
||||
'Phase 1',
|
||||
'Phase 2',
|
||||
'Phase 3',
|
||||
'Phase 4',
|
||||
'Ruby',
|
||||
'Sapphire',
|
||||
'Black Pearl',
|
||||
'Emerald'
|
||||
] as const;
|
||||
|
||||
const wearSuffix = / \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$/;
|
||||
|
||||
export type Config = {
|
||||
starred: boolean;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
/** Name without ★, StatTrak™, Souvenir or wear: the skin grouping key. */
|
||||
base: string;
|
||||
/** Display name of the skin: base with ★ for knives and gloves. */
|
||||
display: string;
|
||||
};
|
||||
|
||||
export function parseConfig(marketHashName: string): Config {
|
||||
let name = marketHashName.trim();
|
||||
const starred = name.startsWith('★ ');
|
||||
if (starred) name = name.slice(2);
|
||||
let stattrak = false;
|
||||
let souvenir = false;
|
||||
if (name.startsWith('StatTrak™ ')) {
|
||||
stattrak = true;
|
||||
name = name.slice('StatTrak™ '.length);
|
||||
}
|
||||
if (name.startsWith('Souvenir ') && name.includes(' | ')) {
|
||||
souvenir = true;
|
||||
name = name.slice('Souvenir '.length);
|
||||
}
|
||||
const wear = wearSuffix.exec(name)?.[1] ?? null;
|
||||
if (wear) name = name.replace(wearSuffix, '');
|
||||
return { starred, stattrak, souvenir, wear, base: name, display: starred ? `★ ${name}` : name };
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return (
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/★/g, '')
|
||||
.replace(/™/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 120) || 'item'
|
||||
);
|
||||
}
|
||||
|
||||
/** Order configs: normal before StatTrak before Souvenir, then wear, then phase. */
|
||||
export function configRank(row: {
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
phase: string | null;
|
||||
}) {
|
||||
const variant = row.souvenir ? 2 : row.stattrak ? 1 : 0;
|
||||
const wear = row.wear ? wearOrder.indexOf(row.wear as (typeof wearOrder)[number]) + 1 : 0;
|
||||
const phase = row.phase ? phaseOrder.indexOf(row.phase as (typeof phaseOrder)[number]) + 1 : 0;
|
||||
return variant * 1000 + wear * 20 + phase;
|
||||
}
|
||||
39
src/lib/math.ts
Normal file
39
src/lib/math.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Math tracker (/trackers/math): Evan Chen's "An Infinitely Large Napkin". Shared by the
|
||||
// server (owner gate, loads) and the page.
|
||||
|
||||
/**
|
||||
* The privacy switch. true: owner-only (404 for everyone else, lock icon in every tracker
|
||||
* list). false: a public, read-only page for everyone else (notes stay owner-only, edits
|
||||
* are always owner-only). Flip this one constant; owner.ts and projects.ts read it.
|
||||
*/
|
||||
export const MATH_PRIVATE = false;
|
||||
|
||||
export const MATH_STATUSES = ['not_started', 'reading', 'exercises', 'done', 'skipped'] as const;
|
||||
export type MathStatus = (typeof MATH_STATUSES)[number];
|
||||
|
||||
export const STATUS_LABEL: Record<MathStatus, string> = {
|
||||
not_started: 'not started',
|
||||
reading: 'reading',
|
||||
exercises: 'exercises',
|
||||
done: 'done',
|
||||
skipped: 'skipped'
|
||||
};
|
||||
|
||||
/** The free PDF of the book; chapters link to it with #page=N (PDF page numbers). */
|
||||
export const NAPKIN_PDF = 'https://venhance.github.io/napkin/Napkin.pdf';
|
||||
export const pdfPage = (page: number) => `${NAPKIN_PDF}#page=${page}`;
|
||||
|
||||
/** "9,10,11,12,18" → "9–12, 18" (numeric keys only; letters are listed as-is). */
|
||||
export function compactKeys(keys: readonly string[]): string {
|
||||
const nums = keys.filter((k) => /^\d+$/.test(k)).map(Number);
|
||||
const other = keys.filter((k) => !/^\d+$/.test(k));
|
||||
nums.sort((a, b) => a - b);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < nums.length;) {
|
||||
let j = i;
|
||||
while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
|
||||
out.push(j - i >= 2 ? `${nums[i]}–${nums[j]}` : nums.slice(i, j + 1).join(', '));
|
||||
i = j + 1;
|
||||
}
|
||||
return [...out, ...other].join(', ');
|
||||
}
|
||||
@@ -105,14 +105,17 @@
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
|
||||
/** Actual part through `projectedFrom` and projected part from the last actual point onward. */
|
||||
function segments(item: ChartSeries) {
|
||||
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
|
||||
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
|
||||
if (!item.projectedFrom) return { actual: item.points, projected: [] };
|
||||
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
|
||||
return split < 0
|
||||
? { solid: [], dashed: item.points }
|
||||
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
|
||||
? { actual: [], projected: item.points }
|
||||
: { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
|
||||
}
|
||||
/** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
|
||||
const lineWidth = (item: ChartSeries, projected: boolean) =>
|
||||
projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
|
||||
function pointAt(points: ChartPoint[], date: string) {
|
||||
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
|
||||
return points.findLast((point) => point.date <= date) ?? null;
|
||||
@@ -236,16 +239,17 @@
|
||||
{/if}
|
||||
{#each series as item (item.label)}
|
||||
{@const parts = segments(item)}
|
||||
{#each [parts.solid, parts.dashed] as points, index (index)}
|
||||
{#each [parts.actual, parts.projected] as points, index (index)}
|
||||
{#if points.length > 1}
|
||||
<path
|
||||
d={path(points)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-width={item.emphasis ? 3 : 1.75}
|
||||
stroke-dasharray={index ? '6 5' : undefined}
|
||||
stroke-linecap={index ? 'butt' : 'round'}
|
||||
stroke-width={lineWidth(item, index === 1)}
|
||||
stroke-opacity={index ? 0.8 : 1}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
data-line={index ? 'projected' : 'actual'}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -306,16 +310,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
|
||||
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
|
||||
<figcaption class="legend">
|
||||
{#each series as item (item.label)}
|
||||
<span class:emphasis={item.emphasis}
|
||||
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
||||
>
|
||||
{/each}
|
||||
{#if series.some((item) => item.dashedFrom)}
|
||||
<span class="line-key first"><span class="key solid"></span>Actual</span>
|
||||
<span class="line-key"><span class="key dashed"></span>Projected</span>
|
||||
{#if series.some((item) => item.projectedFrom)}
|
||||
<span class="line-key first"><span class="key actual"></span>Actual</span>
|
||||
<span class="line-key"><span class="key projected"></span>Projected</span>
|
||||
{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
@@ -419,10 +423,12 @@
|
||||
.key {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
border-top: 2px solid var(--site-muted);
|
||||
border-top: 3px solid var(--site-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.key.dashed {
|
||||
border-top-style: dashed;
|
||||
.key.projected {
|
||||
border-top-width: 1.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
|
||||
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
|
||||
{ key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
|
||||
{ key: 'calmYears', label: 'Calm start (no crashes)', unit: 'yr' },
|
||||
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
|
||||
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
|
||||
]
|
||||
@@ -61,6 +62,7 @@
|
||||
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
|
||||
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
|
||||
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
|
||||
{ key: 'salaryCapGrowthPercent', label: 'Plateau growth (real)', unit: '%/yr' },
|
||||
{ key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
|
||||
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
|
||||
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
|
||||
@@ -101,7 +103,21 @@
|
||||
: 'Not by the end';
|
||||
let active = $derived(params[selected]);
|
||||
let result = $derived(results[selected]);
|
||||
let normalReturn = $derived(marketReturns(active).normal);
|
||||
let markets = $derived(marketReturns(active, result.years[0]?.year));
|
||||
let normalReturn = $derived(markets.normal);
|
||||
/** First crash after any calm start. */
|
||||
let firstCrash = $derived.by(() => {
|
||||
let year = active.firstCrashYear;
|
||||
const every = Math.max(1, Math.round(active.crashEveryYears));
|
||||
while (markets.calmUntil !== null && year <= markets.calmUntil) year += every;
|
||||
return year;
|
||||
});
|
||||
/** Crash and correction years in the first ten years, for the explanation. */
|
||||
let shocks = $derived(
|
||||
Array.from({ length: 11 }, (_, offset) => (result.years[0]?.year ?? 0) + offset)
|
||||
.map((year) => ({ year, ...markets.yearReturn(year) }))
|
||||
.filter((row) => row.kind === 'crash' || row.kind === 'correction')
|
||||
);
|
||||
/** Salary path rows through the retirement year. */
|
||||
let career = $derived(
|
||||
result.years.filter(
|
||||
@@ -163,11 +179,24 @@
|
||||
<p class="muted scenario-portfolio">
|
||||
<strong>{active.label} portfolio:</strong>
|
||||
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
|
||||
{#if markets.calmUntil !== null}No crash or down year through {markets.calmUntil}: those years
|
||||
earn the {active.returnPercent}% average.
|
||||
{/if}
|
||||
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
|
||||
from {active.firstCrashYear}{active.correctionPercent !== 0
|
||||
from {firstCrash}{active.correctionPercent !== 0
|
||||
? `, a ${active.correctionPercent}% correction halfway between`
|
||||
: ''}, and {percent(normalReturn)} in the other years so it averages out.
|
||||
: ''}, and {percent(normalReturn)} in the other years so it averages out{markets.calmUntil !==
|
||||
null
|
||||
? ` (from ${markets.calmUntil + 1})`
|
||||
: ''}. A crash drops over March–October and takes about {Math.ceil(
|
||||
Math.log(1 / (1 + active.crashPercent / 100)) / Math.log(1 + normalReturn)
|
||||
)} years of normal returns to recover.
|
||||
{#if shocks.length}Next ten years: {shocks
|
||||
.map((row) => `${row.year} ${row.kind} ${Math.round(row.rate * 100)}%`)
|
||||
.join(', ')}.{:else}No crash or correction in the next ten years.{/if}
|
||||
{:else}No crashes modelled.{/if}
|
||||
{#if active.salaryCapGrowthPercent > 0}The salary plateau keeps rising {active.salaryCapGrowthPercent}%/yr
|
||||
above inflation, so pay never stalls.{/if}
|
||||
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
|
||||
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
|
||||
the rest of the spending is sold from investments.
|
||||
@@ -180,9 +209,10 @@
|
||||
Joint items from the recurring list in full until the rent starts, then your share, while your
|
||||
partner’s income pays hers, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
|
||||
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
|
||||
grow with inflation; spending grows at the spending rate for ten years, then with inflation.
|
||||
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
|
||||
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
|
||||
grow with inflation (the plateau can grow faster, see above); spending grows at the spending
|
||||
rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
|
||||
Salary bands (base per year, excl. 8% holiday pay): medior €52–68k, senior €65–91k, lead
|
||||
€79–110k (<a
|
||||
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
|
||||
rel="external noreferrer">Chase IT 2026</a
|
||||
>,
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('money tracker calculations', () => {
|
||||
}
|
||||
];
|
||||
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
|
||||
expect(total.dashedFrom).toBe('2026-09-23');
|
||||
expect(total.projectedFrom).toBe('2026-09-23');
|
||||
expect(total.points).toEqual([
|
||||
{ date: '2026-09-01', value: 50000 },
|
||||
{ date: '2026-09-10', value: 70000 },
|
||||
|
||||
@@ -15,8 +15,8 @@ export type ChartSeries = {
|
||||
color: string;
|
||||
points: ChartPoint[];
|
||||
emphasis?: boolean;
|
||||
/** Points after this date are drawn dashed (projected). */
|
||||
dashedFrom?: string;
|
||||
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
|
||||
projectedFrom?: string;
|
||||
};
|
||||
|
||||
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
|
||||
@@ -283,7 +283,7 @@ export function wealthProjection(
|
||||
/**
|
||||
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
|
||||
* date order), so a daily projection over decades keeps its spikes without plotting every day.
|
||||
* The first and last points and the solid/dashed join are always kept.
|
||||
* The first and last points and the actual/projected join are always kept.
|
||||
*/
|
||||
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
|
||||
const limit = Math.max(2, Math.floor(buckets)) * 2;
|
||||
@@ -301,8 +301,8 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
|
||||
const keep = new Uint8Array(line.points.length);
|
||||
keep[0] = 1;
|
||||
keep[line.points.length - 1] = 1;
|
||||
if (line.dashedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
|
||||
if (line.projectedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
|
||||
if (join >= 0) keep[join] = 1;
|
||||
}
|
||||
let bucket = -1;
|
||||
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
|
||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||
|
||||
/**
|
||||
* Joins actual history (solid, through today) with the wealth projection (dashed, after
|
||||
* Joins actual history (solid, through today) with the wealth projection (thin solid line, after
|
||||
* today) so both read as one continuous line per measure.
|
||||
*/
|
||||
export function wealthTimeline(
|
||||
@@ -379,7 +379,7 @@ export function wealthTimeline(
|
||||
label: 'Total',
|
||||
color: 'var(--site-text)',
|
||||
emphasis: true,
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
|
||||
};
|
||||
if (view === 'accounts')
|
||||
@@ -395,13 +395,13 @@ export function wealthTimeline(
|
||||
{
|
||||
label: 'Spendable',
|
||||
color: '#46a758',
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(sumKind(false), spendableProjection?.points ?? [])
|
||||
},
|
||||
{
|
||||
label: 'Invested',
|
||||
color: '#8e4ec6',
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: join(sumKind(true), investedProjection?.points ?? [])
|
||||
}
|
||||
];
|
||||
|
||||
@@ -189,6 +189,49 @@ describe('runScenario', () => {
|
||||
expect(markets.yearReturn(2034).kind).toBe('correction');
|
||||
expect(markets.yearReturn(2028).kind).toBe('normal');
|
||||
});
|
||||
it('keeps the optimistic start calm and its cycle after that', () => {
|
||||
const markets = marketReturns(defaultScenarios.optimistic, 2026);
|
||||
for (let year = 2026; year <= 2031; year++)
|
||||
expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
|
||||
expect(markets.yearReturn(2032).kind).toBe('normal');
|
||||
expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
|
||||
expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
|
||||
// No down year at all in the first five years of the run.
|
||||
const run = runScenario(inputs, defaultScenarios.optimistic);
|
||||
const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
|
||||
growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
|
||||
});
|
||||
it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
|
||||
const shocks = (id: 'realistic' | 'pessimistic') => {
|
||||
const markets = marketReturns(defaultScenarios[id], 2026);
|
||||
return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
|
||||
.map((year) => ({ year, ...markets.yearReturn(year) }))
|
||||
.filter((row) => row.kind === 'crash');
|
||||
};
|
||||
expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
|
||||
expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
|
||||
expect(defaultScenarios.realistic.calmYears).toBe(0);
|
||||
expect(defaultScenarios.pessimistic.calmYears).toBe(0);
|
||||
// Without a start year the calm start never applies.
|
||||
expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
|
||||
});
|
||||
it('keeps the optimistic salary rising every working year', () => {
|
||||
const long = { ...inputs, endDate: '2076-10-24' };
|
||||
const years = runScenario(long, defaultScenarios.optimistic).years.filter(
|
||||
(row) => row.year >= 2027 && row.role !== 'Retired'
|
||||
);
|
||||
expect(years.length).toBeGreaterThan(40);
|
||||
years
|
||||
.slice(1)
|
||||
.forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
|
||||
// A flat plateau stalls once it is reached.
|
||||
const flatCap = runScenario(long, {
|
||||
...defaultScenarios.optimistic,
|
||||
salaryCapGrowthPercent: 0
|
||||
});
|
||||
const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
|
||||
expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
|
||||
});
|
||||
it('hops jobs, caps the salary and retires at the chosen age', () => {
|
||||
const long = { ...inputs, endDate: '2126-10-24' };
|
||||
const result = runScenario(long, {
|
||||
|
||||
@@ -39,6 +39,11 @@ export type ScenarioParams = {
|
||||
firstCrashYear: number;
|
||||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||||
correctionPercent: number;
|
||||
/**
|
||||
* Calm start: no crash or correction from this year through this many years ahead; those
|
||||
* years earn the plain average return (0 = the crash cycle applies from the start).
|
||||
*/
|
||||
calmYears: number;
|
||||
/** Interest on the cash buffer, percent per year. */
|
||||
cashRatePercent: number;
|
||||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||||
@@ -62,6 +67,8 @@ export type ScenarioParams = {
|
||||
hopGapMonths: number;
|
||||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||||
salaryCapEuros: number;
|
||||
/** Real growth of that plateau per year, percent (0 = flat once reached). */
|
||||
salaryCapGrowthPercent: number;
|
||||
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||||
bonusPercentOfTarget: number;
|
||||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
||||
@@ -97,6 +104,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 8,
|
||||
firstCrashYear: 2027,
|
||||
correctionPercent: -8,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 0,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 3,
|
||||
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 15,
|
||||
hopGapMonths: 3,
|
||||
salaryCapEuros: 75000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 0,
|
||||
sideStartEuros: 100,
|
||||
sideEndEuros: 100,
|
||||
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2029,
|
||||
correctionPercent: -10,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 20,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 95000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 250,
|
||||
sideEndEuros: 500,
|
||||
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2030,
|
||||
correctionPercent: -15,
|
||||
calmYears: 5,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 22,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 150000,
|
||||
salaryCapGrowthPercent: 2,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 300,
|
||||
sideEndEuros: 2500,
|
||||
@@ -396,9 +409,11 @@ function monthEnd(first: string) {
|
||||
|
||||
/**
|
||||
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
|
||||
* return makes the whole cycle average out at the expected return.
|
||||
* return makes the whole cycle average out at the expected return. With `calmYears`, the years
|
||||
* from `startYear` through `startYear + calmYears` have no crash or correction and earn the plain
|
||||
* average return; the cycle keeps its phase after that.
|
||||
*/
|
||||
export function marketReturns(params: ScenarioParams) {
|
||||
export function marketReturns(params: ScenarioParams, startYear?: number) {
|
||||
const pct = (value: number) => value / 100;
|
||||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||||
const crashes = params.crashPercent !== 0;
|
||||
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
|
||||
const normal = crashes
|
||||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||||
: pct(params.returnPercent);
|
||||
const calmUntil =
|
||||
startYear !== undefined && params.calmYears > 0
|
||||
? startYear + Math.round(params.calmYears)
|
||||
: null;
|
||||
return {
|
||||
normal,
|
||||
calmUntil,
|
||||
yearReturn(year: number) {
|
||||
if (calmUntil !== null && year <= calmUntil)
|
||||
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
|
||||
if (!crashes || year < params.firstCrashYear)
|
||||
return { kind: 'normal' as const, rate: normal };
|
||||
const offset = (year - params.firstCrashYear) % every;
|
||||
@@ -444,7 +466,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
const gapStart = addMonths(facts.contractEnd, 1);
|
||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
||||
const markets = marketReturns(params);
|
||||
const markets = marketReturns(params, Number(inputs.today.slice(0, 4)));
|
||||
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||||
const isHopYear = (year: number) =>
|
||||
params.hopEveryYears > 0 &&
|
||||
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
const cap =
|
||||
((params.salaryCapEuros * 100) / 12) *
|
||||
deflator *
|
||||
Math.pow(1 + pct(params.salaryCapGrowthPercent), Math.max(0, index) / 12);
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
// compounding gives the same monthly return.
|
||||
const market = markets.yearReturn(year);
|
||||
const monthRate =
|
||||
market.kind === 'normal'
|
||||
market.kind === 'normal' || market.kind === 'calm'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
@@ -848,7 +873,7 @@ export function scenarioProjection(result: ScenarioResult, todaysEuros = false):
|
||||
];
|
||||
}
|
||||
|
||||
/** One dashed total line per scenario, for comparing them on the wealth chart. */
|
||||
/** One projected total line per scenario, for comparing them on the wealth chart. */
|
||||
export function scenarioCompareLines(
|
||||
results: Record<ScenarioId, ScenarioResult>,
|
||||
params: Record<ScenarioId, ScenarioParams>,
|
||||
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
|
||||
return scenarioIds.map((id) => ({
|
||||
label: params[id].label,
|
||||
color: scenarioColors[id],
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: results[id].points
|
||||
.filter((point) => point.date > today)
|
||||
.map((point) => ({
|
||||
|
||||
1331
src/lib/napkin/MathMap.svelte
Normal file
1331
src/lib/napkin/MathMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
124
src/lib/napkin/map.test.ts
Normal file
124
src/lib/napkin/map.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildMapLayout, MAP_GRID, PART_ACCENT, PART_LINKS } from './map';
|
||||
|
||||
// Chapter counts per part, as in the seeded Napkin.
|
||||
const COUNTS: Record<string, number> = {
|
||||
'1': 3,
|
||||
'2': 3,
|
||||
'3': 3,
|
||||
'4': 7,
|
||||
'5': 3,
|
||||
'6': 4,
|
||||
'7': 3,
|
||||
'8': 5,
|
||||
'9': 4,
|
||||
'10': 5,
|
||||
'11': 3,
|
||||
'12': 4,
|
||||
'13': 6,
|
||||
'14': 6,
|
||||
'15': 5,
|
||||
'16': 3,
|
||||
'17': 4,
|
||||
'18': 6,
|
||||
'19': 5,
|
||||
'20': 6,
|
||||
'21': 4,
|
||||
'22': 3,
|
||||
appendix: 5
|
||||
};
|
||||
let n = 0;
|
||||
const parts = Object.entries(COUNTS).map(([key, count]) => ({
|
||||
key,
|
||||
chapters: Array.from({ length: count }, () => String(++n))
|
||||
}));
|
||||
const layout = buildMapLayout(parts);
|
||||
|
||||
/** Points along an absolute M/L/C path. */
|
||||
function samples(d: string) {
|
||||
const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?/g) ?? [];
|
||||
const pts: [number, number][] = [];
|
||||
let cur: [number, number] = [0, 0];
|
||||
let i = 0;
|
||||
const num = () => Number(tokens[i++]);
|
||||
while (i < tokens.length) {
|
||||
const cmd = tokens[i++];
|
||||
if (cmd === 'M' || cmd === 'L') {
|
||||
const p: [number, number] = [num(), num()];
|
||||
if (cmd === 'L')
|
||||
for (let t = 0; t <= 1; t += 0.05)
|
||||
pts.push([cur[0] + (p[0] - cur[0]) * t, cur[1] + (p[1] - cur[1]) * t]);
|
||||
cur = p;
|
||||
} else if (cmd === 'C') {
|
||||
const [c1, c2, p] = [
|
||||
[num(), num()],
|
||||
[num(), num()],
|
||||
[num(), num()]
|
||||
];
|
||||
for (let t = 0; t <= 1; t += 0.05) {
|
||||
const u = 1 - t;
|
||||
pts.push([
|
||||
u ** 3 * cur[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t ** 3 * p[0],
|
||||
u ** 3 * cur[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t ** 3 * p[1]
|
||||
]);
|
||||
}
|
||||
cur = p as [number, number];
|
||||
} else throw new Error(`unexpected path token ${cmd}`);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
describe('napkin map layout', () => {
|
||||
it('places every part and every chapter', () => {
|
||||
expect(layout.regions.map((r) => r.key).sort()).toEqual(Object.keys(COUNTS).sort());
|
||||
expect(layout.nodes).toHaveLength(n);
|
||||
for (const key of Object.keys(COUNTS)) {
|
||||
expect(MAP_GRID[key]).toBeDefined();
|
||||
expect(PART_ACCENT[key]).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps regions apart and nodes inside their region', () => {
|
||||
const rs = layout.regions;
|
||||
for (let a = 0; a < rs.length; a++)
|
||||
for (let b = a + 1; b < rs.length; b++) {
|
||||
const [p, q] = [rs[a], rs[b]];
|
||||
const overlap = p.x < q.x + q.w && q.x < p.x + p.w && p.y < q.y + q.h && q.y < p.y + p.h;
|
||||
expect(overlap, `${p.key} overlaps ${q.key}`).toBe(false);
|
||||
}
|
||||
for (const node of layout.nodes) {
|
||||
const r = rs.find((x) => x.key === node.partKey)!;
|
||||
expect(node.x).toBeGreaterThanOrEqual(r.x);
|
||||
expect(node.y).toBeGreaterThanOrEqual(r.y);
|
||||
expect(node.x + node.w).toBeLessThanOrEqual(r.x + r.w);
|
||||
expect(node.y + node.h).toBeLessThanOrEqual(r.y + r.h);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws one edge per part link, optional ones flagged', () => {
|
||||
expect(layout.edges).toHaveLength(PART_LINKS.length);
|
||||
expect(layout.edges.filter((e) => e.optional)).toHaveLength(
|
||||
PART_LINKS.filter((l) => l.optional).length
|
||||
);
|
||||
expect(PART_LINKS.filter((l) => l.optional).length).toBe(10);
|
||||
for (const e of layout.edges) expect(e.d).toMatch(/^M/);
|
||||
});
|
||||
|
||||
it('never routes an edge through an unrelated region', () => {
|
||||
for (const e of layout.edges) {
|
||||
const others = layout.regions.filter((r) => r.key !== e.from && r.key !== e.to);
|
||||
for (const [x, y] of samples(e.d))
|
||||
for (const r of others) {
|
||||
const inside = x > r.x + 1 && x < r.x + r.w - 1 && y > r.y + 1 && y < r.y + r.h - 1;
|
||||
expect(inside, `${e.id} crosses part ${r.key} at ${x},${y}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('flows downward from the roots', () => {
|
||||
const row = (k: string) => MAP_GRID[k][1];
|
||||
for (const l of PART_LINKS) expect(row(l.to)).toBeGreaterThanOrEqual(row(l.from) - 1);
|
||||
for (const root of ['2', '3', '4', '21']) expect(row(root)).toBeLessThanOrEqual(1);
|
||||
for (const leaf of ['18', '20', '15']) expect(row(leaf)).toBe(5);
|
||||
});
|
||||
});
|
||||
303
src/lib/napkin/map.ts
Normal file
303
src/lib/napkin/map.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The Napkin as a 2D map: one region per part, one node per chapter, prereq edges between parts.
|
||||
*
|
||||
* Region placement follows the book's dependency chart ("Advice for the reader", PDF p. 9,
|
||||
* tex/frontmatter/digraph.tex): Abs Alg, Lin Alg, Topology and Set Theory are the roots and
|
||||
* everything flows down to Alg Geo, Alg Top and Alg NT. Regions sit on a hand-placed grid; edges
|
||||
* are routed through the empty channels between rows and the gutters between columns, so they
|
||||
* never cut through a region.
|
||||
*/
|
||||
|
||||
/** Part → [column, row] on the map grid. */
|
||||
export const MAP_GRID: Record<string, readonly [number, number]> = {
|
||||
appendix: [0, 0],
|
||||
'1': [3, 0],
|
||||
'21': [0, 1],
|
||||
'2': [1, 1],
|
||||
'4': [3, 1],
|
||||
'3': [5, 1],
|
||||
'22': [0, 2],
|
||||
'5': [1, 2],
|
||||
'6': [2, 2],
|
||||
'7': [4, 2],
|
||||
'8': [5, 2],
|
||||
'17': [2, 3],
|
||||
'12': [4, 3],
|
||||
'9': [5, 3],
|
||||
'10': [6, 3],
|
||||
'19': [1, 4],
|
||||
'16': [2, 4],
|
||||
'14': [3, 4],
|
||||
'13': [4, 4],
|
||||
'11': [6, 4],
|
||||
'20': [1, 5],
|
||||
'18': [2, 5],
|
||||
'15': [3, 5]
|
||||
};
|
||||
|
||||
export type PartLink = { from: string; to: string; optional: boolean };
|
||||
|
||||
/**
|
||||
* Part-level prereqs from the book's chart. `optional` = dotted in the book (\coreqc), solid
|
||||
* otherwise (\prereqc). Additions: Starting Out leads into Abs Alg and Topology, Measure →
|
||||
* Probability and Diff Geo → Riemann Surfaces (one block in the chart each).
|
||||
*/
|
||||
export const PART_LINKS: readonly PartLink[] = (
|
||||
[
|
||||
['1', '2'],
|
||||
['1', '3'],
|
||||
['2', '4', true],
|
||||
['2', '5'],
|
||||
['2', '6'],
|
||||
['4', '6'],
|
||||
['4', '7'],
|
||||
['2', '17'],
|
||||
['4', '17', true],
|
||||
['3', '17', true],
|
||||
['17', '6', true],
|
||||
['3', '8'],
|
||||
['3', '9'],
|
||||
['8', '9', true],
|
||||
['16', '9', true],
|
||||
['3', '10'],
|
||||
['8', '10', true],
|
||||
['10', '11'],
|
||||
['4', '12'],
|
||||
['3', '12'],
|
||||
['8', '12', true],
|
||||
['12', '13'],
|
||||
['2', '14'],
|
||||
['4', '14'],
|
||||
['14', '15'],
|
||||
['5', '15'],
|
||||
['3', '16'],
|
||||
['5', '16'],
|
||||
['16', '17', true],
|
||||
['16', '18'],
|
||||
['17', '18'],
|
||||
['2', '19'],
|
||||
['3', '19'],
|
||||
['17', '19', true],
|
||||
['19', '20'],
|
||||
['17', '20'],
|
||||
['21', '22']
|
||||
] as const
|
||||
).map(([from, to, optional]) => ({ from, to, optional: optional === true }));
|
||||
|
||||
/** Accent per area of maths (region badge stripe), kept apart from the status colours. */
|
||||
export const PART_ACCENT: Record<string, string> = {
|
||||
'1': '#e4e4e7',
|
||||
appendix: '#a1a1aa',
|
||||
'2': '#f472b6',
|
||||
'5': '#f472b6',
|
||||
'6': '#f472b6',
|
||||
'17': '#f472b6',
|
||||
'4': '#818cf8',
|
||||
'7': '#818cf8',
|
||||
'3': '#2dd4bf',
|
||||
'16': '#2dd4bf',
|
||||
'18': '#2dd4bf',
|
||||
'8': '#fb923c',
|
||||
'9': '#fb923c',
|
||||
'10': '#fb923c',
|
||||
'11': '#fb923c',
|
||||
'12': '#fb923c',
|
||||
'13': '#fb923c',
|
||||
'14': '#e879f9',
|
||||
'15': '#e879f9',
|
||||
'19': '#a3e635',
|
||||
'20': '#a3e635',
|
||||
'21': '#94a3b8',
|
||||
'22': '#94a3b8'
|
||||
};
|
||||
|
||||
export const MAP = {
|
||||
regionWidth: 240,
|
||||
gutter: 96,
|
||||
rowGap: 120,
|
||||
head: 46,
|
||||
padX: 10,
|
||||
padBottom: 10,
|
||||
nodeHeight: 34,
|
||||
nodeGap: 6,
|
||||
portGap: 18,
|
||||
laneGap: 12
|
||||
} as const;
|
||||
|
||||
export type MapRegion = {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
col: number;
|
||||
row: number;
|
||||
};
|
||||
export type MapNode = { key: string; partKey: string; x: number; y: number; w: number; h: number };
|
||||
export type MapEdge = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
optional: boolean;
|
||||
d: string;
|
||||
/** End point, for the arrow head. */
|
||||
end: { x: number; y: number; dir: 'down' | 'up' | 'right' };
|
||||
};
|
||||
export type MapLayout = {
|
||||
regions: MapRegion[];
|
||||
nodes: MapNode[];
|
||||
edges: MapEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const r1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function buildMapLayout(
|
||||
parts: readonly { key: string; chapters: readonly string[] }[],
|
||||
links: readonly PartLink[] = PART_LINKS
|
||||
): MapLayout {
|
||||
const M = MAP;
|
||||
const W = M.regionWidth;
|
||||
const colX = (c: number) => c * (W + M.gutter);
|
||||
const heightOf = (n: number) => M.head + n * (M.nodeHeight + M.nodeGap) - M.nodeGap + M.padBottom;
|
||||
|
||||
const placed = parts.filter((p) => MAP_GRID[p.key]);
|
||||
const rows = Math.max(...placed.map((p) => MAP_GRID[p.key][1])) + 1;
|
||||
const cols = Math.max(...placed.map((p) => MAP_GRID[p.key][0])) + 1;
|
||||
const rowHeight = Array.from({ length: rows }, () => 0);
|
||||
for (const p of placed) {
|
||||
const [, r] = MAP_GRID[p.key];
|
||||
rowHeight[r] = Math.max(rowHeight[r], heightOf(p.chapters.length));
|
||||
}
|
||||
const rowTop: number[] = [0];
|
||||
for (let r = 1; r < rows; r++) rowTop[r] = rowTop[r - 1] + rowHeight[r - 1] + M.rowGap;
|
||||
/** Channel between row r and r + 1: [channelTop, channelBottom]. */
|
||||
const chTop = (r: number) => rowTop[r] + rowHeight[r];
|
||||
const chBot = (r: number) => rowTop[r + 1];
|
||||
|
||||
const regions: MapRegion[] = [];
|
||||
const nodes: MapNode[] = [];
|
||||
for (const p of placed) {
|
||||
const [col, row] = MAP_GRID[p.key];
|
||||
const reg = {
|
||||
key: p.key,
|
||||
col,
|
||||
row,
|
||||
x: colX(col),
|
||||
y: rowTop[row],
|
||||
w: W,
|
||||
h: heightOf(p.chapters.length)
|
||||
};
|
||||
regions.push(reg);
|
||||
p.chapters.forEach((key, i) =>
|
||||
nodes.push({
|
||||
key,
|
||||
partKey: p.key,
|
||||
x: reg.x + M.padX,
|
||||
y: reg.y + M.head + i * (M.nodeHeight + M.nodeGap),
|
||||
w: W - 2 * M.padX,
|
||||
h: M.nodeHeight
|
||||
})
|
||||
);
|
||||
}
|
||||
const reg = new Map(regions.map((r) => [r.key, r]));
|
||||
const usable = links.filter((l) => reg.has(l.from) && reg.has(l.to));
|
||||
|
||||
// Ports: spread each region's edges along its bottom (outgoing down / incoming up) and top.
|
||||
type Side = 'top' | 'bottom';
|
||||
const portLists = new Map<string, { id: string; other: number }[]>();
|
||||
const addPort = (key: string, side: Side, id: string, other: number) => {
|
||||
const k = `${key}:${side}`;
|
||||
if (!portLists.has(k)) portLists.set(k, []);
|
||||
portLists.get(k)!.push({ id, other });
|
||||
};
|
||||
const idOf = (l: PartLink) => `${l.from}-${l.to}`;
|
||||
for (const l of usable) {
|
||||
const a = reg.get(l.from)!;
|
||||
const b = reg.get(l.to)!;
|
||||
if (a.row === b.row) continue;
|
||||
const down = b.row > a.row;
|
||||
addPort(a.key, down ? 'bottom' : 'top', idOf(l), b.x + b.w / 2);
|
||||
addPort(b.key, down ? 'top' : 'bottom', idOf(l), a.x + a.w / 2);
|
||||
}
|
||||
const portX = (key: string, side: Side, id: string) => {
|
||||
const r = reg.get(key)!;
|
||||
const list = [...portLists.get(`${key}:${side}`)!].sort(
|
||||
(p, q) => p.other - q.other || (p.id < q.id ? -1 : 1)
|
||||
);
|
||||
const i = list.findIndex((p) => p.id === id);
|
||||
const gap = Math.min(M.portGap, (r.w * 0.7) / Math.max(1, list.length));
|
||||
return r.x + r.w / 2 + (i - (list.length - 1) / 2) * gap;
|
||||
};
|
||||
/** Is x free of regions in every row strictly between a and b? */
|
||||
const freeAt = (x: number, a: number, b: number) =>
|
||||
regions.every((r) => r.row <= a || r.row >= b || x < r.x - 14 || x > r.x + r.w + 14);
|
||||
const gutterLoad = new Map<number, number>();
|
||||
const s = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
if (Math.abs(x0 - x1) < 0.5) return ` L${r1(x1)},${r1(y1)}`;
|
||||
const m = (y0 + y1) / 2;
|
||||
return ` C${r1(x0)},${r1(m)} ${r1(x1)},${r1(m)} ${r1(x1)},${r1(y1)}`;
|
||||
};
|
||||
|
||||
const edges: MapEdge[] = [];
|
||||
for (const l of usable) {
|
||||
const a = reg.get(l.from)!;
|
||||
const b = reg.get(l.to)!;
|
||||
const id = idOf(l);
|
||||
let d: string;
|
||||
let end: MapEdge['end'];
|
||||
if (a.row === b.row) {
|
||||
// Same row (Abs Alg ⇢ Lin Alg): straight across at header height.
|
||||
const y = a.y + 23;
|
||||
const [x0, x1] = a.x < b.x ? [a.x + a.w, b.x] : [a.x, b.x + b.w];
|
||||
d = `M${r1(x0)},${r1(y)} L${r1(x1)},${r1(y)}`;
|
||||
end = { x: x1, y, dir: 'right' };
|
||||
} else if (b.row < a.row) {
|
||||
// Upward (optional back-links, adjacent rows only).
|
||||
const sx = portX(a.key, 'top', id);
|
||||
const tx = portX(b.key, 'bottom', id);
|
||||
const ty = b.y + b.h;
|
||||
d = `M${r1(sx)},${r1(a.y)}` + s(sx, a.y, tx, chTop(b.row)) + ` L${r1(tx)},${r1(ty)}`;
|
||||
end = { x: tx, y: ty, dir: 'up' };
|
||||
} else {
|
||||
const sx = portX(a.key, 'bottom', id);
|
||||
const tx = portX(b.key, 'top', id);
|
||||
const sy = a.y + a.h;
|
||||
const ty = b.y;
|
||||
d = `M${r1(sx)},${r1(sy)} L${r1(sx)},${r1(chTop(a.row))}`;
|
||||
if (b.row === a.row + 1) d += s(sx, chTop(a.row), tx, ty);
|
||||
else if (freeAt(tx, a.row, b.row))
|
||||
d += s(sx, chTop(a.row), tx, chBot(a.row)) + ` L${r1(tx)},${r1(ty)}`;
|
||||
else if (freeAt(sx, a.row, b.row))
|
||||
d += ` L${r1(sx)},${r1(chTop(b.row - 1))}` + s(sx, chTop(b.row - 1), tx, ty);
|
||||
else {
|
||||
// Down a gutter between columns: the one with the least detour, then the least used.
|
||||
let best = { g: 0, cost: Infinity };
|
||||
for (let g = 0; g <= cols; g++) {
|
||||
const gx = colX(g) - M.gutter / 2;
|
||||
const cost = Math.abs(sx - gx) + Math.abs(tx - gx) + (gutterLoad.get(g) ?? 0) * 40;
|
||||
if (cost < best.cost) best = { g, cost };
|
||||
}
|
||||
const n = gutterLoad.get(best.g) ?? 0;
|
||||
gutterLoad.set(best.g, n + 1);
|
||||
const lane = (n % 2 ? -1 : 1) * Math.ceil(n / 2) * M.laneGap;
|
||||
const gx = colX(best.g) - M.gutter / 2 + lane;
|
||||
d +=
|
||||
s(sx, chTop(a.row), gx, chBot(a.row)) +
|
||||
` L${r1(gx)},${r1(chTop(b.row - 1))}` +
|
||||
s(gx, chTop(b.row - 1), tx, ty);
|
||||
}
|
||||
end = { x: tx, y: ty, dir: 'down' };
|
||||
}
|
||||
edges.push({ id, from: l.from, to: l.to, optional: l.optional, d, end });
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
nodes,
|
||||
edges,
|
||||
width: colX(cols) - M.gutter,
|
||||
height: rowTop[rows - 1] + rowHeight[rows - 1]
|
||||
};
|
||||
}
|
||||
79
src/lib/plant.test.ts
Normal file
79
src/lib/plant.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
KIND_TAB,
|
||||
PLANT_KINDS,
|
||||
entryHref,
|
||||
kindFromSegment,
|
||||
kindFromTab,
|
||||
layoutFlow,
|
||||
matches,
|
||||
slugify,
|
||||
tabHref
|
||||
} from './plant';
|
||||
|
||||
describe('plant tracker', () => {
|
||||
it('lays the flow out top to bottom, ignoring dashed loops', () => {
|
||||
// oil → clean → split → col → dimer, dimer ⇢ col (dashed), clean ⇢ ester, col → ester
|
||||
const nodes = [1, 2, 3, 4, 5, 6].map((id) => ({ id, sort: id }));
|
||||
const edges = [
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 3, dashed: false },
|
||||
{ from: 3, to: 4, dashed: false },
|
||||
{ from: 4, to: 5, dashed: false },
|
||||
{ from: 5, to: 4, dashed: true },
|
||||
{ from: 2, to: 6, dashed: true },
|
||||
{ from: 4, to: 6, dashed: false }
|
||||
];
|
||||
const { placed, width, height } = layoutFlow(nodes, edges);
|
||||
const layer = Object.fromEntries(placed.map((p) => [p.id, p.layer]));
|
||||
expect(layer).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 4 });
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('survives a solid cycle', () => {
|
||||
const { placed } = layoutFlow(
|
||||
[
|
||||
{ id: 1, sort: 0 },
|
||||
{ id: 2, sort: 1 }
|
||||
],
|
||||
[
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 1, dashed: false }
|
||||
]
|
||||
);
|
||||
expect(placed.map((p) => p.layer)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('maps kinds to URLs and back', () => {
|
||||
expect(entryHref({ kind: 'process_step', slug: 'splitting' })).toBe(
|
||||
'/trackers/cargill/process-step/splitting'
|
||||
);
|
||||
expect(kindFromSegment('process-step')).toBe('process_step');
|
||||
expect(kindFromSegment('nope')).toBeNull();
|
||||
expect(slugify('Lab (QC) – Ëster')).toBe('lab-qc-ester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs and search', () => {
|
||||
it('maps kinds to tabs and back', () => {
|
||||
for (const k of PLANT_KINDS) expect(kindFromTab(KIND_TAB[k])).toBe(k);
|
||||
expect(kindFromTab('nope')).toBeNull();
|
||||
expect(kindFromTab(null)).toBeNull();
|
||||
expect(tabHref('process_step')).toBe('/trackers/cargill?tab=process');
|
||||
expect(tabHref(null)).toBe('/trackers/cargill');
|
||||
});
|
||||
it('matches all words, ignoring case and accents', () => {
|
||||
const e = {
|
||||
title: 'Dimer acids',
|
||||
slug: 'dimer-acids',
|
||||
summary: 'Twee vetzuren',
|
||||
body: null,
|
||||
tags: ['product']
|
||||
};
|
||||
expect(matches(e, 'DIMER twee')).toBe(true);
|
||||
expect(matches(e, 'dimer ester')).toBe(false);
|
||||
expect(matches({ ...e, title: 'Crème' }, 'creme')).toBe(true);
|
||||
expect(matches(e, ' ')).toBe(true);
|
||||
});
|
||||
});
|
||||
184
src/lib/plant.ts
Normal file
184
src/lib/plant.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Cargill plant tracker (/trackers/cargill): what the Cargill Bioindustrial plant does.
|
||||
// Shared by the server (loads, validation) and the pages. Owner-only: the whole path is in
|
||||
// ownerOnlyPrefixes (#lib/server/owner), so everyone else gets a 404.
|
||||
|
||||
/** Entry kinds (kept in sync with the entries_kind_check constraint in the plant migration). */
|
||||
export const PLANT_KINDS = [
|
||||
'process_step',
|
||||
'product',
|
||||
'department',
|
||||
'equipment',
|
||||
'glossary',
|
||||
'question',
|
||||
'note',
|
||||
'person_role'
|
||||
] as const;
|
||||
export type PlantKind = (typeof PLANT_KINDS)[number];
|
||||
|
||||
export const KIND_LABEL: Record<PlantKind, { one: string; many: string }> = {
|
||||
process_step: { one: 'process step', many: 'process' },
|
||||
product: { one: 'product', many: 'products' },
|
||||
department: { one: 'department', many: 'departments' },
|
||||
equipment: { one: 'equipment', many: 'equipment' },
|
||||
glossary: { one: 'glossary word', many: 'glossary' },
|
||||
question: { one: 'question', many: 'questions' },
|
||||
note: { one: 'note', many: 'notes' },
|
||||
person_role: { one: 'role', many: 'roles' }
|
||||
};
|
||||
|
||||
/** Tile gradient per kind (colourful icons on a black-and-white page). */
|
||||
export const KIND_COLOR: Record<PlantKind, readonly [string, string]> = {
|
||||
process_step: ['#a78bfa', '#6d28d9'],
|
||||
product: ['#fcd34d', '#d97706'],
|
||||
department: ['#7dd3fc', '#0369a1'],
|
||||
equipment: ['#fdba74', '#c2410c'],
|
||||
glossary: ['#6ee7b7', '#047857'],
|
||||
question: ['#f9a8d4', '#be185d'],
|
||||
note: ['#d4d4d8', '#52525b'],
|
||||
person_role: ['#93c5fd', '#1d4ed8']
|
||||
};
|
||||
|
||||
/** confirmed: checked at work. assumed: from the guide / general knowledge. open: unknown. */
|
||||
export const CONFIDENCES = ['confirmed', 'assumed', 'open'] as const;
|
||||
export type Confidence = (typeof CONFIDENCES)[number];
|
||||
|
||||
export const RELATIONS = ['feeds_into', 'made_in', 'uses', 'part_of', 'related'] as const;
|
||||
export type Relation = (typeof RELATIONS)[number];
|
||||
|
||||
/** How a link reads from each end: "A feeds into B" / "B is fed by A". */
|
||||
export const RELATION_LABEL: Record<Relation, { out: string; in: string }> = {
|
||||
feeds_into: { out: 'feeds into', in: 'fed by' },
|
||||
made_in: { out: 'made in', in: 'makes' },
|
||||
uses: { out: 'uses', in: 'used by' },
|
||||
part_of: { out: 'part of', in: 'includes' },
|
||||
related: { out: 'related', in: 'related' }
|
||||
};
|
||||
|
||||
export const slugify = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
export const entryHref = (e: { kind: string; slug: string }) =>
|
||||
`/trackers/cargill/${e.kind.replace('_', '-')}/${e.slug}`;
|
||||
/** URL segment ("process-step") back to the kind ("process_step"). */
|
||||
export const kindFromSegment = (seg: string): PlantKind | null => {
|
||||
const k = seg.replace(/-/g, '_');
|
||||
return (PLANT_KINDS as readonly string[]).includes(k) ? (k as PlantKind) : null;
|
||||
};
|
||||
|
||||
// ---- Process flow layout (pure, unit-tested) -------------------------------------------
|
||||
|
||||
export type FlowNode = { id: number; sort: number };
|
||||
export type FlowEdge = { from: number; to: number; dashed: boolean };
|
||||
export type Placed = { id: number; layer: number; x: number; y: number };
|
||||
|
||||
export const FLOW = { w: 160, h: 50, gapX: 16, gapY: 46, pad: 8 } as const;
|
||||
|
||||
/**
|
||||
* Layered top-to-bottom layout. Layers are the longest path over solid edges (dashed ones,
|
||||
* like "sort again" loops, don't push nodes down; real cycles are broken by DFS order).
|
||||
* Within a layer nodes sit near their parents (barycentre), ties by sort order.
|
||||
*/
|
||||
export function layoutFlow(nodes: FlowNode[], edges: FlowEdge[]) {
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const solid = edges.filter((e) => !e.dashed && ids.has(e.from) && ids.has(e.to));
|
||||
const sort = new Map(nodes.map((n) => [n.id, n.sort]));
|
||||
const out = new Map<number, number[]>();
|
||||
for (const e of solid) out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
|
||||
// Drop back edges (cycles) with a DFS in sort order.
|
||||
const state = new Map<number, 1 | 2>();
|
||||
const keep: FlowEdge[] = [];
|
||||
const order = [...nodes].sort((a, b) => a.sort - b.sort || a.id - b.id);
|
||||
const visit = (id: number) => {
|
||||
state.set(id, 1);
|
||||
for (const to of out.get(id) ?? []) {
|
||||
if (state.get(to) === 1) continue; // back edge
|
||||
keep.push({ from: id, to, dashed: false });
|
||||
if (!state.has(to)) visit(to);
|
||||
}
|
||||
state.set(id, 2);
|
||||
};
|
||||
for (const n of order) if (!state.has(n.id)) visit(n.id);
|
||||
const layer = new Map(nodes.map((n) => [n.id, 0]));
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let changed = false;
|
||||
for (const e of keep) {
|
||||
const l = layer.get(e.from)! + 1;
|
||||
if (l > layer.get(e.to)!) {
|
||||
layer.set(e.to, l);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
const layers: number[][] = [];
|
||||
for (const n of order) (layers[layer.get(n.id)!] ??= []).push(n.id);
|
||||
const xIndex = new Map<number, number>();
|
||||
const parents = new Map<number, number[]>();
|
||||
for (const e of keep) parents.set(e.to, [...(parents.get(e.to) ?? []), e.from]);
|
||||
for (const row of layers) {
|
||||
if (!row) continue;
|
||||
const bary = (id: number) => {
|
||||
const ps = (parents.get(id) ?? []).filter((p) => xIndex.has(p));
|
||||
return ps.length ? ps.reduce((s, p) => s + xIndex.get(p)!, 0) / ps.length : Infinity;
|
||||
};
|
||||
row.sort((a, b) => {
|
||||
const d = bary(a) - bary(b);
|
||||
return Number.isFinite(d) && d !== 0 ? d : sort.get(a)! - sort.get(b)! || a - b;
|
||||
});
|
||||
row.forEach((id, i) => xIndex.set(id, i - (row.length - 1) / 2));
|
||||
}
|
||||
const widest = Math.max(1, ...layers.map((r) => r?.length ?? 0));
|
||||
const width = widest * FLOW.w + (widest - 1) * FLOW.gapX + 2 * FLOW.pad;
|
||||
const rows = layers.length;
|
||||
const height = rows * FLOW.h + Math.max(0, rows - 1) * FLOW.gapY + 2 * FLOW.pad;
|
||||
const placed: Placed[] = nodes.map((n) => {
|
||||
const l = layer.get(n.id)!;
|
||||
return {
|
||||
id: n.id,
|
||||
layer: l,
|
||||
x: width / 2 + xIndex.get(n.id)! * (FLOW.w + FLOW.gapX) - FLOW.w / 2,
|
||||
y: FLOW.pad + l * (FLOW.h + FLOW.gapY)
|
||||
};
|
||||
});
|
||||
return { placed, width, height };
|
||||
}
|
||||
|
||||
// ---- Tabs ------------------------------------------------------------------------------
|
||||
|
||||
/** ?tab= value per kind on /trackers/cargill (no tab = the overview). */
|
||||
export const KIND_TAB: Record<PlantKind, string> = {
|
||||
process_step: 'process',
|
||||
product: 'products',
|
||||
department: 'departments',
|
||||
equipment: 'equipment',
|
||||
glossary: 'glossary',
|
||||
question: 'questions',
|
||||
note: 'notes',
|
||||
person_role: 'roles'
|
||||
};
|
||||
export const kindFromTab = (tab: string | null): PlantKind | null =>
|
||||
(Object.entries(KIND_TAB).find(([, t]) => t === tab)?.[0] as PlantKind | undefined) ?? null;
|
||||
export const tabHref = (kind: PlantKind | null) =>
|
||||
kind ? `/trackers/cargill?tab=${KIND_TAB[kind]}` : '/trackers/cargill';
|
||||
|
||||
/** Case- and accent-insensitive match over title, slug, summary, tags and body. */
|
||||
export function matches(
|
||||
e: { title: string; slug: string; summary: string | null; body: string | null; tags: string[] },
|
||||
q: string
|
||||
) {
|
||||
const norm = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
const words = norm(q).split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = norm([e.title, e.slug, e.summary ?? '', e.tags.join(' '), e.body ?? ''].join(' '));
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
46
src/lib/plant/ConfidencePill.svelte
Normal file
46
src/lib/plant/ConfidencePill.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Confidence } from '#lib/plant.js';
|
||||
let { value }: { value: Confidence } = $props();
|
||||
const TITLE: Record<Confidence, string> = {
|
||||
confirmed: 'Confirmed: checked or certain',
|
||||
assumed: 'Assumed: from the guide, not yet checked at work',
|
||||
open: 'Open: still unknown'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="pill {value}" title={TITLE[value]}>{value}</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
padding: 0.08rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill::before {
|
||||
content: '';
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
.confirmed {
|
||||
color: #10b981;
|
||||
border-color: rgb(16 185 129 / 0.4);
|
||||
}
|
||||
.assumed {
|
||||
color: #f59e0b;
|
||||
border-color: rgb(245 158 11 / 0.4);
|
||||
}
|
||||
.open {
|
||||
color: #ec4899;
|
||||
border-color: rgb(236 72 153 / 0.4);
|
||||
}
|
||||
</style>
|
||||
242
src/lib/plant/EntryForm.svelte
Normal file
242
src/lib/plant/EntryForm.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
// Owner form to add or edit one entry (markdown body). Posts to ?/save on the current page;
|
||||
// both /trackers/cargill pages share the actions (#lib/server/plant-actions).
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CONFIDENCES, KIND_LABEL, PLANT_KINDS, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: string;
|
||||
};
|
||||
let {
|
||||
entry,
|
||||
kind = 'note',
|
||||
entries,
|
||||
onclose,
|
||||
openAfterCreate = false
|
||||
}: {
|
||||
entry?: Entry;
|
||||
kind?: PlantKind;
|
||||
entries: Pick<Entry, 'id' | 'kind' | 'title'>[];
|
||||
onclose: () => void;
|
||||
/** After adding, go to the new entry's page (used by the generic "add entry" form). */
|
||||
openAfterCreate?: boolean;
|
||||
} = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let chosenKind = $state<PlantKind>(entry?.kind ?? kind);
|
||||
const defaultConfidence = $derived(chosenKind === 'question' ? 'open' : 'assumed');
|
||||
|
||||
const submit: SubmitFunction = () => {
|
||||
busy = true;
|
||||
error = null;
|
||||
return async ({ result, update }) => {
|
||||
busy = false;
|
||||
if (result.type === 'failure') {
|
||||
error = String(result.data?.message ?? 'Could not save');
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success') {
|
||||
const href = result.data?.href as string | undefined;
|
||||
const here = location.pathname;
|
||||
if (href && !entry && openAfterCreate)
|
||||
await goto(resolve(href as '/'), { refreshAll: true });
|
||||
// Editing on the entry's own page may change its slug or kind: follow it.
|
||||
else if (href && entry && here.startsWith('/trackers/cargill/') && here !== href)
|
||||
await goto(resolve(href as '/'), { replace: true, refreshAll: true });
|
||||
else await update({ reset: false, navigate: false });
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
await update({ navigate: false });
|
||||
};
|
||||
};
|
||||
const parents = $derived(entries.filter((e) => e.id !== entry?.id));
|
||||
</script>
|
||||
|
||||
<form class="edit" method="POST" action="?/save" use:enhance={submit}>
|
||||
{#if entry}<input type="hidden" name="id" value={entry.id} />{/if}
|
||||
<label
|
||||
>kind<select name="kind" bind:value={chosenKind}>
|
||||
{#each PLANT_KINDS as k (k)}<option value={k}>{KIND_LABEL[k].one}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="span2"
|
||||
>title<input
|
||||
name="title"
|
||||
value={entry?.title ?? ''}
|
||||
maxlength="200"
|
||||
required
|
||||
placeholder={chosenKind === 'question' ? 'What do I want to know?' : 'Name'}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>confidence<select name="confidence" value={entry?.confidence ?? defaultConfidence}>
|
||||
{#each CONFIDENCES as c (c)}<option value={c}>{c}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="wide"
|
||||
>summary<input
|
||||
name="summary"
|
||||
value={entry?.summary ?? ''}
|
||||
maxlength="500"
|
||||
placeholder="one line"
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>details (markdown)<textarea name="body" rows="7" value={entry?.body ?? ''} maxlength="50000"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="span2"
|
||||
>tags<input
|
||||
name="tags"
|
||||
value={entry?.tags.join(', ') ?? ''}
|
||||
maxlength="400"
|
||||
placeholder="comma, separated"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>part of<select name="parent_id" value={entry?.parentId ?? ''}>
|
||||
<option value="">—</option>
|
||||
{#each parents as p (p.id)}<option value={p.id}>{p.title} · {KIND_LABEL[p.kind].one}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>slug<input
|
||||
name="slug"
|
||||
value={entry?.slug ?? ''}
|
||||
maxlength="80"
|
||||
placeholder="from title"
|
||||
pattern="[a-z0-9\-]*"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>order<input
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value={entry?.sortOrder ?? ''}
|
||||
placeholder="0"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>source<input
|
||||
name="source"
|
||||
value={entry?.source ?? ''}
|
||||
maxlength="200"
|
||||
placeholder="where this came from"
|
||||
/></label
|
||||
>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={onclose}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{entry ? 'save' : 'add'}</button>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.edit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
.span2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
height: 2.1rem;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.primary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.edit {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
src/lib/plant/FlowDiagram.svelte
Normal file
256
src/lib/plant/FlowDiagram.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
// The plant's process flow, drawn from feeds_into links: every entry on such a link is a
|
||||
// node (process steps as cards, raw materials / outputs as dashed pills), laid out top to
|
||||
// bottom by layoutFlow (#lib/plant). Dashed links are optional paths or loops back.
|
||||
import { FLOW, KIND_COLOR, entryHref, layoutFlow, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Node = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
type Link = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: string;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
confidence?: string;
|
||||
};
|
||||
let { entries, links }: { entries: Node[]; links: Link[] } = $props();
|
||||
|
||||
const flow = $derived.by(() => {
|
||||
// Only the process: feeds_into links that touch a process step (not product -> department).
|
||||
const kindOf = new Map(entries.map((e) => [e.id, e.kind]));
|
||||
const feeds = links.filter(
|
||||
(l) =>
|
||||
l.relation === 'feeds_into' &&
|
||||
(kindOf.get(l.fromId) === 'process_step' || kindOf.get(l.toId) === 'process_step')
|
||||
);
|
||||
const ids = new Set(feeds.flatMap((l) => [l.fromId, l.toId]));
|
||||
// Order: process steps by their sort order; inputs first, outputs last.
|
||||
const nodes = entries
|
||||
.filter((e) => ids.has(e.id))
|
||||
.map((e) => ({
|
||||
...e,
|
||||
sort:
|
||||
(e.kind === 'process_step' ? 1000 : feeds.some((l) => l.toId === e.id) ? 5000 : 0) +
|
||||
e.sortOrder
|
||||
}));
|
||||
const { placed, width, height } = layoutFlow(
|
||||
nodes.map((n) => ({ id: n.id, sort: n.sort })),
|
||||
feeds.map((l) => ({ from: l.fromId, to: l.toId, dashed: l.dashed }))
|
||||
);
|
||||
const pos = new Map(placed.map((p) => [p.id, p]));
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
// Spread several edges over a node's bottom (out) and top (in) edge.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const outs = new Map<number, Link[]>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const ins = new Map<number, Link[]>();
|
||||
for (const l of feeds) {
|
||||
outs.set(l.fromId, [...(outs.get(l.fromId) ?? []), l]);
|
||||
ins.set(l.toId, [...(ins.get(l.toId) ?? []), l]);
|
||||
}
|
||||
const port = (list: Link[], l: Link, x: (o: Link) => number) => {
|
||||
const sorted = [...list].sort((a, b) => x(a) - x(b));
|
||||
const i = sorted.indexOf(l);
|
||||
return (i - (sorted.length - 1) / 2) * Math.min(22, (FLOW.w - 30) / sorted.length);
|
||||
};
|
||||
const cx = (id: number) => pos.get(id)!.x + FLOW.w / 2;
|
||||
const placedNodes = placed.map((p) => ({ ...p, node: byId.get(p.id)! }));
|
||||
const mid = width / 2;
|
||||
const M = 12; // clearance between a lane and a node
|
||||
let minX = 0;
|
||||
let maxX = width;
|
||||
const edges = feeds.map((l) => {
|
||||
const a = pos.get(l.fromId)!;
|
||||
const b = pos.get(l.toId)!;
|
||||
let d: string;
|
||||
let lx: number;
|
||||
let ly: number;
|
||||
if (b.layer > a.layer) {
|
||||
const x1 = cx(l.fromId) + port(outs.get(l.fromId)!, l, (o) => cx(o.toId));
|
||||
const y1 = a.y + FLOW.h;
|
||||
const x2 = cx(l.toId) + port(ins.get(l.toId)!, l, (o) => cx(o.fromId));
|
||||
const y2 = b.y - 4;
|
||||
// Nodes on the layers in between that a straight-ish line would cross.
|
||||
const between = placed.filter((p) => p.layer > a.layer && p.layer < b.layer);
|
||||
const hits = (x: number) => between.some((p) => x > p.x - M && x < p.x + FLOW.w + M);
|
||||
const lo = Math.min(x1, x2);
|
||||
const hi = Math.max(x1, x2);
|
||||
const crossed = between.filter((p) => p.x + FLOW.w + M > lo && p.x - M < hi);
|
||||
if (!crossed.length) {
|
||||
const my = (y2 - y1) * 0.5;
|
||||
d = `M${x1} ${y1} C${x1} ${y1 + my} ${x2} ${y2 - my} ${x2} ${y2}`;
|
||||
lx = (x1 + x2) / 2;
|
||||
ly = (y1 + y2) / 2;
|
||||
} else {
|
||||
// Long edge: run down a free vertical lane beside the nodes it would cross.
|
||||
let right = Math.max(...crossed.map((p) => p.x + FLOW.w)) + M;
|
||||
while (hits(right)) right += FLOW.gapX;
|
||||
let left = Math.min(...crossed.map((p) => p.x)) - M;
|
||||
while (hits(left)) left -= FLOW.gapX;
|
||||
const cost = (x: number) => Math.abs(x - x1) + Math.abs(x - x2);
|
||||
const lane = cost(left) < cost(right) ? left : right;
|
||||
const g = FLOW.gapY / 2;
|
||||
const ya = y1 + g;
|
||||
const yb = y2 - g;
|
||||
d =
|
||||
`M${x1} ${y1} C${x1} ${y1 + g * 0.7} ${lane} ${ya - g * 0.7} ${lane} ${ya}` +
|
||||
` L${lane} ${yb} C${lane} ${yb + g * 0.7} ${x2} ${y2 - g * 0.7} ${x2} ${y2}`;
|
||||
lx = lane;
|
||||
ly = (ya + yb) / 2;
|
||||
minX = Math.min(minX, lane - 40);
|
||||
maxX = Math.max(maxX, lane + 40);
|
||||
}
|
||||
} else {
|
||||
// Loop back (or sideways): out of the outer side and back in on the same side.
|
||||
const leftSide = a.x + FLOW.w / 2 + (b.x + FLOW.w / 2) < 2 * mid;
|
||||
const x1 = leftSide ? a.x : a.x + FLOW.w;
|
||||
const x2 = leftSide ? b.x - 4 : b.x + FLOW.w + 4;
|
||||
const y1 = a.y + FLOW.h / 2;
|
||||
const y2 = b.y + FLOW.h / 2;
|
||||
const bulge = 30 + Math.abs(y1 - y2) * 0.1;
|
||||
const out = leftSide ? Math.min(x1, x2) - bulge : Math.max(x1, x2) + bulge;
|
||||
d = `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;
|
||||
lx = leftSide ? Math.min(x1, x2) - bulge * 0.75 : Math.max(x1, x2) + bulge * 0.75;
|
||||
ly = (y1 + y2) / 2;
|
||||
minX = Math.min(minX, out - 30);
|
||||
maxX = Math.max(maxX, out + 30);
|
||||
}
|
||||
return { ...l, d, lx, ly };
|
||||
});
|
||||
return { nodes: placedNodes, edges, minX, width: maxX - minX, height };
|
||||
});
|
||||
const clip = (s: string | null, n: number) =>
|
||||
!s ? '' : s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
||||
</script>
|
||||
|
||||
{#if flow.nodes.length}
|
||||
<svg
|
||||
class="flow"
|
||||
viewBox="{flow.minX} 0 {flow.width} {flow.height}"
|
||||
style:max-width="{flow.width}px"
|
||||
role="img"
|
||||
aria-label="Process flow of the plant"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="plant-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M1 1L9 5L1 9z" class="arrowhead" />
|
||||
</marker>
|
||||
</defs>
|
||||
{#each flow.edges as e (e.id)}
|
||||
<path
|
||||
class={['edge', { dashed: e.dashed || (e.confidence ?? 'confirmed') !== 'confirmed' }]}
|
||||
d={e.d}
|
||||
marker-end="url(#plant-arrow)"
|
||||
/>
|
||||
{/each}
|
||||
{#each flow.edges as e (e.id)}
|
||||
{#if e.label}
|
||||
{@const w = e.label.length * 5.4 + 10}
|
||||
<g class="elabel" transform="translate({e.lx} {e.ly})">
|
||||
<rect x={-w / 2} y="-8" width={w} height="16" rx="8" />
|
||||
<text text-anchor="middle" dy="3.5">{e.label}</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each flow.nodes as p (p.id)}
|
||||
{@const n = p.node}
|
||||
{@const step = n.kind === 'process_step'}
|
||||
<a href={entryHref(n)} class="node-link">
|
||||
<g class={['node', { step, terminal: !step }]} transform="translate({p.x} {p.y})">
|
||||
<title>{n.title}{n.summary ? ` — ${n.summary}` : ''}</title>
|
||||
<rect width={FLOW.w} height={FLOW.h} rx={step ? 10 : 25} />
|
||||
<circle cx="14" cy={FLOW.h / 2} r="4.5" fill={KIND_COLOR[n.kind][1]} />
|
||||
<text class="t" x="25" y={n.summary ? 21 : 29}>{clip(n.title, 20)}</text>
|
||||
{#if n.summary}<text class="s" x="25" y="36">{clip(n.summary, 26)}</text>{/if}
|
||||
</g>
|
||||
</a>
|
||||
{/each}
|
||||
</svg>
|
||||
{:else}
|
||||
<p class="empty">No flow yet: link process steps with “feeds into”.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--site-muted);
|
||||
stroke-opacity: 0.7;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.edge.dashed {
|
||||
stroke-dasharray: 4 4;
|
||||
stroke-opacity: 0.55;
|
||||
}
|
||||
.arrowhead {
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.elabel rect {
|
||||
fill: var(--site-bg);
|
||||
stroke: var(--site-border);
|
||||
}
|
||||
.elabel text {
|
||||
font-size: 9.5px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.node rect {
|
||||
fill: var(--site-surface);
|
||||
stroke: var(--site-border);
|
||||
stroke-width: 1.2;
|
||||
transition:
|
||||
stroke 0.15s,
|
||||
fill 0.15s;
|
||||
}
|
||||
.node.terminal rect {
|
||||
fill: var(--site-bg);
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.node-link:hover rect,
|
||||
.node-link:focus-visible rect {
|
||||
stroke: var(--site-text);
|
||||
}
|
||||
.node-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.t {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
fill: var(--site-text);
|
||||
}
|
||||
.s {
|
||||
font-size: 10px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* Keep the labels readable on phones: the card scrolls sideways instead. */
|
||||
.flow {
|
||||
min-width: 30rem;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
51
src/lib/plant/KindIcon.svelte
Normal file
51
src/lib/plant/KindIcon.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
// A small gradient tile with the kind's icon (colourful icons on a black-and-white page).
|
||||
import { KIND_COLOR, KIND_LABEL, type PlantKind } from '#lib/plant.js';
|
||||
import Workflow from 'lucide-svelte/icons/workflow';
|
||||
import Package from 'lucide-svelte/icons/package';
|
||||
import Building2 from 'lucide-svelte/icons/building-2';
|
||||
import Wrench from 'lucide-svelte/icons/wrench';
|
||||
import BookA from 'lucide-svelte/icons/book-a';
|
||||
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import UserRound from 'lucide-svelte/icons/user-round';
|
||||
|
||||
let { kind, size = 22 }: { kind: PlantKind; size?: number } = $props();
|
||||
|
||||
const ICONS = {
|
||||
process_step: Workflow,
|
||||
product: Package,
|
||||
department: Building2,
|
||||
equipment: Wrench,
|
||||
glossary: BookA,
|
||||
question: CircleQuestionMark,
|
||||
note: StickyNote,
|
||||
person_role: UserRound
|
||||
} as const;
|
||||
const Icon = $derived(ICONS[kind] ?? StickyNote);
|
||||
const colors = $derived(KIND_COLOR[kind] ?? KIND_COLOR.note);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="kind-icon"
|
||||
style:--from={colors[0]}
|
||||
style:--to={colors[1]}
|
||||
style:--size="{size}px"
|
||||
title={KIND_LABEL[kind]?.one ?? kind}
|
||||
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
|
||||
>
|
||||
|
||||
<style>
|
||||
.kind-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: calc(var(--size) * 0.3);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MATH_PRIVATE } from './math';
|
||||
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
|
||||
export const projects = [
|
||||
{
|
||||
@@ -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',
|
||||
|
||||
40
src/lib/radio/BandIcon.svelte
Normal file
40
src/lib/radio/BandIcon.svelte
Normal 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
285
src/lib/radio/Banks.svelte
Normal 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>
|
||||
75
src/lib/radio/FreqForm.svelte
Normal file
75
src/lib/radio/FreqForm.svelte
Normal 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>
|
||||
241
src/lib/radio/FreqList.svelte
Normal file
241
src/lib/radio/FreqList.svelte
Normal 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>
|
||||
213
src/lib/radio/IssPasses.svelte
Normal file
213
src/lib/radio/IssPasses.svelte
Normal 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>
|
||||
126
src/lib/radio/LogList.svelte
Normal file
126
src/lib/radio/LogList.svelte
Normal 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
286
src/lib/radio/Nets.svelte
Normal 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>
|
||||
179
src/lib/radio/QuickLog.svelte
Normal file
179
src/lib/radio/QuickLog.svelte
Normal 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>
|
||||
29
src/lib/radio/Signal.svelte
Normal file
29
src/lib/radio/Signal.svelte
Normal 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
34
src/lib/radio/Tile.svelte
Normal 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
186
src/lib/radio/UpNext.svelte
Normal 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
107
src/lib/radio/index.test.ts
Normal 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
336
src/lib/radio/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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
217
src/lib/server/ai-setup.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
|
||||
// each with entries grouped by section. Labels only: values that look like secrets are refused.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
AI_SETUP_COLORS,
|
||||
AI_SETUP_SCOPES,
|
||||
AI_SETUP_SECTIONS,
|
||||
aiSetupEntries,
|
||||
aiSetupProviders
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
|
||||
const SECRET =
|
||||
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
|
||||
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
|
||||
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
|
||||
|
||||
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
|
||||
const optional = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
const idField = z.coerce.number().int().positive();
|
||||
|
||||
const providerInput = z.object({
|
||||
name: trimmed(60),
|
||||
products: optional(120),
|
||||
color: z.enum(AI_SETUP_COLORS).catch('zinc')
|
||||
});
|
||||
const entryInput = z
|
||||
.object({
|
||||
section: z.enum(AI_SETUP_SECTIONS),
|
||||
name: trimmed(120),
|
||||
note: optional(400),
|
||||
scope: z
|
||||
.enum(['', ...AI_SETUP_SCOPES])
|
||||
.nullish()
|
||||
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
|
||||
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
|
||||
body: optional(20000)
|
||||
})
|
||||
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
|
||||
message: SECRET_MSG
|
||||
});
|
||||
|
||||
export type SetupEntry = {
|
||||
id: number;
|
||||
section: (typeof AI_SETUP_SECTIONS)[number];
|
||||
name: string;
|
||||
note: string | null;
|
||||
scope: string | null;
|
||||
link: string | null;
|
||||
body: string | null;
|
||||
bodyHtml: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SetupProvider = {
|
||||
id: number;
|
||||
name: string;
|
||||
products: string | null;
|
||||
color: (typeof AI_SETUP_COLORS)[number];
|
||||
updatedAt: string;
|
||||
entries: SetupEntry[];
|
||||
};
|
||||
|
||||
export async function loadSetup(): Promise<SetupProvider[]> {
|
||||
const providers = await db
|
||||
.select()
|
||||
.from(aiSetupProviders)
|
||||
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(aiSetupEntries)
|
||||
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
|
||||
return providers.map((p) => {
|
||||
const own = entries.filter((e) => e.providerId === p.id);
|
||||
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
products: p.products,
|
||||
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
|
||||
? (p.color as SetupProvider['color'])
|
||||
: 'zinc',
|
||||
updatedAt: new Date(latest).toISOString(),
|
||||
entries: own.map((e) => ({
|
||||
id: e.id,
|
||||
section: e.section as SetupEntry['section'],
|
||||
name: e.name,
|
||||
note: e.note,
|
||||
scope: e.scope,
|
||||
link: e.link,
|
||||
body: e.body,
|
||||
bodyHtml: e.body ? renderBody(e.body) : null,
|
||||
updatedAt: e.updatedAt.toISOString()
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type Result = { ok: true } | { ok: false; message: string };
|
||||
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
|
||||
const fields = (form: FormData) =>
|
||||
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||
|
||||
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
|
||||
const touch = (providerId: number) =>
|
||||
db
|
||||
.update(aiSetupProviders)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(aiSetupProviders.id, providerId));
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
|
||||
|
||||
export async function addProvider(form: FormData): Promise<Result> {
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupProviders);
|
||||
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function addEntry(form: FormData): Promise<Result> {
|
||||
const providerId = idField.safeParse(form.get('provider_id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!providerId.success) return { ok: false, message: 'Missing provider' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.providerId, providerId.data));
|
||||
await db
|
||||
.insert(aiSetupEntries)
|
||||
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
|
||||
await touch(providerId.data);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [row] = await db
|
||||
.update(aiSetupEntries)
|
||||
.set(parsed.data)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
const [row] = await db
|
||||
.delete(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -24,6 +24,24 @@ const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
|
||||
});
|
||||
|
||||
describe('bank transaction mapping', () => {
|
||||
it('tags interest and dividend payouts, nothing else', () => {
|
||||
const credit = (remittance: string[], code?: string) =>
|
||||
mapBankTransaction(
|
||||
tx({
|
||||
credit_debit_indicator: 'CRDT',
|
||||
creditor: undefined,
|
||||
debtor: { name: 'Revolut' },
|
||||
remittance_information: remittance,
|
||||
bank_transaction_code: code ? { code } : undefined
|
||||
})
|
||||
)!.category;
|
||||
expect(credit(['Savings vault interest'])).toBe('interest');
|
||||
expect(credit(['Interest PAID EUR Class R IE000AZVL3K0'])).toBe('interest');
|
||||
expect(credit(['Revolut'], 'INTEREST')).toBe('interest');
|
||||
expect(credit(['Dividend Payment: VWRL'])).toBe('dividend');
|
||||
expect(credit(['From JUHEUI KIM & STAN RUNGE'], 'TRANSFER')).toBeNull();
|
||||
expect(mapBankTransaction(tx({ remittance_information: ['Interest'] }))!.category).toBeNull();
|
||||
});
|
||||
it('parses decimal amounts exactly', () => {
|
||||
expect(amountToCents('12.34')).toBe(1234);
|
||||
expect(amountToCents('-0.5')).toBe(-50);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SessionResponse
|
||||
} from './enable-banking';
|
||||
import { EnableBankingError } from './enable-banking';
|
||||
import { investmentIncomeCategory } from './investment-income';
|
||||
import { findSumMatch } from './reconcile';
|
||||
|
||||
export type MappedTransaction = {
|
||||
@@ -21,6 +22,8 @@ export type MappedTransaction = {
|
||||
counterparty: string | null;
|
||||
amountCents: number;
|
||||
bookingStatus: string;
|
||||
/** 'interest' / 'dividend' for investment income payouts, else null. */
|
||||
category: string | null;
|
||||
raw: BankTransaction;
|
||||
};
|
||||
|
||||
@@ -79,6 +82,17 @@ export function mapBankTransaction(tx: BankTransaction): MappedTransaction | nul
|
||||
counterparty,
|
||||
amountCents,
|
||||
bookingStatus: tx.status ?? 'BOOK',
|
||||
category: investmentIncomeCategory({
|
||||
amountCents,
|
||||
kind: tx.bank_transaction_code?.code,
|
||||
texts: [
|
||||
description,
|
||||
...remittance,
|
||||
tx.bank_transaction_code?.description,
|
||||
tx.bank_transaction_code?.sub_code,
|
||||
tx.note
|
||||
]
|
||||
}),
|
||||
raw: tx
|
||||
};
|
||||
}
|
||||
@@ -435,10 +449,11 @@ export async function syncBankAccounts(
|
||||
for (const { row } of absorbed)
|
||||
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
|
||||
for (const { id, tx } of updates) {
|
||||
// Keep Stan's own description/category; attach the bank identity.
|
||||
// Keep Stan's own description/category (only fill an empty one); attach the bank identity.
|
||||
await q`
|
||||
update money.transactions set external_id = ${tx.externalId},
|
||||
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
||||
category = coalesce(category, ${tx.category}),
|
||||
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
||||
raw = ${q.json(tx.raw as never)}
|
||||
where id = ${id} and external_id is null`;
|
||||
@@ -447,10 +462,10 @@ export async function syncBankAccounts(
|
||||
await q`
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, description, amount_cents, external_id, source,
|
||||
booking_status, counterparty, raw, value_date)
|
||||
booking_status, counterparty, category, raw, value_date)
|
||||
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
|
||||
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
|
||||
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||
${tx.counterparty}, ${tx.category}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||
on conflict (account_id, external_id) do nothing`;
|
||||
}
|
||||
});
|
||||
|
||||
275
src/lib/server/cs-prices.ts
Normal file
275
src/lib/server/cs-prices.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { configRank, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
|
||||
|
||||
// Read side of the CS2 price archive (cs.*): skin search, a skin's configs with their latest
|
||||
// price per source, and one config's daily history. USD streams are shown in EUR using the
|
||||
// ECB rate of that day (cs.fx_rates); `native` keeps the source's own currency.
|
||||
|
||||
export type Stream = { id: number; source: string; currency: string; kind: string; label: string };
|
||||
|
||||
export const kinds = [
|
||||
['weapon', 'Weapons'],
|
||||
['knife', 'Knives'],
|
||||
['gloves', 'Gloves'],
|
||||
['sticker', 'Stickers'],
|
||||
['container', 'Cases'],
|
||||
['agent', 'Agents'],
|
||||
['charm', 'Charms'],
|
||||
['patch', 'Patches'],
|
||||
['music-kit', 'Music kits'],
|
||||
['graffiti', 'Graffiti'],
|
||||
['collectible', 'Pins & coins'],
|
||||
['key', 'Keys']
|
||||
] as const;
|
||||
|
||||
export async function streams(): Promise<Stream[]> {
|
||||
const rows = await db.execute(sql`SELECT id, source, currency, kind,
|
||||
coalesce(label, source || ' ' || replace(kind, '-', ' ')) AS label
|
||||
FROM cs.price_streams WHERE id IS NOT NULL ORDER BY id`);
|
||||
return (rows as unknown as Stream[]).map((row) => ({ ...row, id: Number(row.id) }));
|
||||
}
|
||||
|
||||
export async function overview() {
|
||||
const [row] = (await db.execute(sql`SELECT
|
||||
(SELECT count(*)::int FROM cs.skins) AS skins,
|
||||
(SELECT count(*)::int FROM cs.items) AS configs,
|
||||
(SELECT max(day)::text FROM cs.daily_prices) AS latest_day,
|
||||
(SELECT count(*)::int FROM cs.daily_prices
|
||||
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_rows,
|
||||
(SELECT count(DISTINCT item_id)::int FROM cs.daily_prices
|
||||
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_configs,
|
||||
(SELECT greatest(reltuples::bigint, 0) FROM pg_class
|
||||
WHERE oid = 'cs.daily_prices'::regclass)::bigint AS rows,
|
||||
(SELECT min(day)::text FROM cs.daily_prices) AS earliest_day,
|
||||
(SELECT max(last_fetched_at) FROM cs.price_streams) AS fetched_at`)) as unknown as {
|
||||
skins: number;
|
||||
configs: number;
|
||||
latest_day: string | null;
|
||||
latest_rows: number;
|
||||
latest_configs: number;
|
||||
rows: string;
|
||||
earliest_day: string | null;
|
||||
fetched_at: Date | null;
|
||||
}[];
|
||||
return row;
|
||||
}
|
||||
|
||||
export type SkinHit = {
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
image: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
configs: number;
|
||||
from_cents: number | null;
|
||||
listings: number | null;
|
||||
};
|
||||
|
||||
/** Skins matching every word of `q`, with today's cheapest EUR listing across their configs. */
|
||||
export async function searchSkins(q: string, kind: string, sort: string, limit = 60, offset = 0) {
|
||||
const words = q
|
||||
.trim()
|
||||
.slice(0, 100)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.map(
|
||||
(word) => `%${word.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
|
||||
);
|
||||
const filter = words.length
|
||||
? sql.join(
|
||||
words.map((word) => sql`s.name ILIKE ${word} ESCAPE '\\'`),
|
||||
sql` AND `
|
||||
)
|
||||
: sql`true`;
|
||||
const kindFilter =
|
||||
kind && kinds.some(([value]) => value === kind) ? sql`s.kind = ${kind}` : sql`true`;
|
||||
const order =
|
||||
sort === 'price'
|
||||
? sql`p.from_cents DESC NULLS LAST, s.name`
|
||||
: sort === 'name'
|
||||
? sql`s.name`
|
||||
: sql`p.listings DESC NULLS LAST, s.name`;
|
||||
const rows = await db.execute(sql`
|
||||
WITH listing AS (
|
||||
SELECT id FROM cs.price_streams WHERE source = 'skinport' AND kind = 'lowest-listing'
|
||||
), latest AS (
|
||||
SELECT max(day) AS day FROM cs.daily_prices WHERE stream_id = (SELECT id FROM listing)
|
||||
), p AS (
|
||||
SELECT i.skin_id, min(dp.price_cents)::int AS from_cents, sum(dp.volume)::int AS listings
|
||||
FROM cs.daily_prices dp JOIN cs.items i ON i.id = dp.item_id
|
||||
WHERE dp.stream_id = (SELECT id FROM listing) AND dp.day = (SELECT day FROM latest)
|
||||
GROUP BY i.skin_id
|
||||
)
|
||||
SELECT s.slug, s.name, s.kind, s.image, s.rarity, s.rarity_color, p.from_cents, p.listings,
|
||||
(SELECT count(*)::int FROM cs.items i WHERE i.skin_id = s.id) AS configs
|
||||
FROM cs.skins s LEFT JOIN p ON p.skin_id = s.id
|
||||
WHERE ${filter} AND ${kindFilter}
|
||||
ORDER BY ${words.length ? sql`(s.name ILIKE ${words[0].slice(1)} ESCAPE '\\') DESC,` : sql``} ${order}
|
||||
LIMIT ${Math.min(120, Math.max(1, limit))} OFFSET ${Math.min(50_000, Math.max(0, offset))}`);
|
||||
return rows as unknown as SkinHit[];
|
||||
}
|
||||
|
||||
export type ConfigRow = {
|
||||
id: number;
|
||||
market_hash_name: string;
|
||||
phase: string | null;
|
||||
wear: string | null;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
icon_url: string | null;
|
||||
prices: {
|
||||
stream: number;
|
||||
cents: number;
|
||||
eur_cents: number;
|
||||
volume: number | null;
|
||||
day: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Variant = 'normal' | 'stattrak' | 'souvenir';
|
||||
export const variantOf = (row: { stattrak: boolean; souvenir: boolean }): Variant =>
|
||||
row.souvenir ? 'souvenir' : row.stattrak ? 'stattrak' : 'normal';
|
||||
const shortToWear = Object.fromEntries(Object.entries(wearShort).map(([wear, s]) => [s, wear]));
|
||||
|
||||
export async function loadSkin(slug: string) {
|
||||
const [skin] = (await db.execute(sql`SELECT id, slug, name, kind, weapon, pattern, category,
|
||||
rarity, rarity_color, image, min_float, max_float, collections
|
||||
FROM cs.skins WHERE slug = ${slug}`)) as unknown as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
weapon: string | null;
|
||||
pattern: string | null;
|
||||
category: string | null;
|
||||
rarity: string | null;
|
||||
rarity_color: string | null;
|
||||
image: string | null;
|
||||
min_float: number | null;
|
||||
max_float: number | null;
|
||||
collections: string[] | null;
|
||||
}[];
|
||||
if (!skin) return null;
|
||||
// Latest price per config and stream within the last 10 days, converted to EUR.
|
||||
const configs = (await db.execute(sql`
|
||||
WITH fx AS (SELECT usd_eur FROM cs.fx_rates ORDER BY day DESC LIMIT 1)
|
||||
SELECT i.id, i.market_hash_name, i.phase, i.wear, i.stattrak, i.souvenir, i.icon_url,
|
||||
coalesce((SELECT json_agg(json_build_object('stream', l.stream_id, 'cents', l.price_cents,
|
||||
'eur_cents', CASE WHEN st.currency = 'USD'
|
||||
THEN round(l.price_cents * coalesce((SELECT usd_eur FROM fx), 0.92))::int
|
||||
ELSE l.price_cents END,
|
||||
'volume', l.volume, 'day', l.day::text) ORDER BY l.stream_id)
|
||||
FROM (SELECT DISTINCT ON (dp.stream_id) dp.stream_id, dp.price_cents, dp.volume, dp.day
|
||||
FROM cs.daily_prices dp WHERE dp.item_id = i.id AND dp.day >= current_date - 10
|
||||
ORDER BY dp.stream_id, dp.day DESC) l
|
||||
JOIN cs.price_streams st ON st.id = l.stream_id), '[]'::json) AS prices
|
||||
FROM cs.items i WHERE i.skin_id = ${skin.id}`)) as unknown as ConfigRow[];
|
||||
configs.sort((a, b) => configRank(a) - configRank(b));
|
||||
return { skin, configs };
|
||||
}
|
||||
|
||||
/** Pick the config from ?v=&wear=&phase=, falling back to the cheapest priced normal config. */
|
||||
export function pickConfig(configs: ConfigRow[], params: URLSearchParams) {
|
||||
const variant = params.get('v') as Variant | null;
|
||||
const wear = shortToWear[params.get('wear') ?? ''] ?? null;
|
||||
const phase = params.get('phase') || null;
|
||||
// Default to the most traded normal config (most listings/sales across sources).
|
||||
const liquidity = (row: ConfigRow) => row.prices.reduce((sum, p) => sum + (p.volume ?? 0), 0);
|
||||
const fallback = () =>
|
||||
configs
|
||||
.filter((row) => row.prices.length && !row.phase && variantOf(row) === 'normal')
|
||||
.sort((a, b) => liquidity(b) - liquidity(a))[0] ??
|
||||
configs.find((row) => row.prices.length) ??
|
||||
configs[0] ??
|
||||
null;
|
||||
if (!variant && !params.has('wear') && !params.has('phase')) return fallback();
|
||||
const exact = configs.find(
|
||||
(row) =>
|
||||
(!variant || variantOf(row) === variant) &&
|
||||
(params.has('wear') ? row.wear === wear : true) &&
|
||||
(params.has('phase') ? row.phase === phase : !row.phase)
|
||||
);
|
||||
if (exact) return exact;
|
||||
const near = configs.find(
|
||||
(row) => (!variant || variantOf(row) === variant) && (!wear || row.wear === wear)
|
||||
);
|
||||
return near ?? fallback();
|
||||
}
|
||||
|
||||
export type SeriesPoint = [string, number];
|
||||
|
||||
/** Daily EUR series per stream for one config. Doppler phases also get the phase-blind
|
||||
* listing's streams they lack (e.g. Steam has no phases), labelled as "any phase". */
|
||||
export async function loadSeries(config: ConfigRow, from: string | null) {
|
||||
const ids = [config.id];
|
||||
if (config.phase) {
|
||||
const [base] = (await db.execute(sql`SELECT id FROM cs.items
|
||||
WHERE market_hash_name = ${config.market_hash_name} AND phase IS NULL`)) as unknown as {
|
||||
id: number;
|
||||
}[];
|
||||
if (base) ids.push(base.id);
|
||||
}
|
||||
const rows = (await db.execute(sql`
|
||||
SELECT dp.item_id, dp.stream_id, dp.day::text AS day, dp.volume,
|
||||
CASE WHEN st.currency = 'USD' THEN round(dp.price_cents * coalesce(
|
||||
(SELECT usd_eur FROM cs.fx_rates f WHERE f.day <= dp.day ORDER BY f.day DESC LIMIT 1),
|
||||
(SELECT usd_eur FROM cs.fx_rates ORDER BY day LIMIT 1), 0.92))::int
|
||||
ELSE dp.price_cents END AS eur_cents
|
||||
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||
WHERE dp.item_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`,`
|
||||
)}) ${from ? sql`AND dp.day >= ${from}::date` : sql``}
|
||||
ORDER BY dp.day`)) as unknown as {
|
||||
item_id: number;
|
||||
stream_id: number;
|
||||
day: string;
|
||||
volume: number | null;
|
||||
eur_cents: number;
|
||||
}[];
|
||||
const own = new Set(rows.filter((r) => r.item_id === config.id).map((r) => r.stream_id));
|
||||
const series = new Map<string, { stream: number; anyPhase: boolean; points: SeriesPoint[] }>();
|
||||
for (const row of rows) {
|
||||
const anyPhase = row.item_id !== config.id;
|
||||
if (anyPhase && own.has(row.stream_id)) continue;
|
||||
const key = `${row.stream_id}:${anyPhase}`;
|
||||
const entry = series.get(key) ?? { stream: Number(row.stream_id), anyPhase, points: [] };
|
||||
entry.points.push([row.day, row.eur_cents]);
|
||||
series.set(key, entry);
|
||||
}
|
||||
const windows = (await db.execute(sql`SELECT item_id, source, currency, span, median_cents,
|
||||
avg_cents, min_cents, max_cents, volume FROM cs.price_windows
|
||||
WHERE item_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`,`
|
||||
)})`)) as unknown as {
|
||||
item_id: number;
|
||||
source: string;
|
||||
currency: string;
|
||||
span: string;
|
||||
median_cents: number | null;
|
||||
avg_cents: number | null;
|
||||
min_cents: number | null;
|
||||
max_cents: number | null;
|
||||
volume: number | null;
|
||||
}[];
|
||||
const [fx] = (await db.execute(
|
||||
sql`SELECT usd_eur::float AS rate, day::text FROM cs.fx_rates ORDER BY day DESC LIMIT 1`
|
||||
)) as unknown as { rate: number; day: string }[];
|
||||
return {
|
||||
series: [...series.values()],
|
||||
windows: windows
|
||||
.filter(
|
||||
(w) =>
|
||||
w.item_id === config.id ||
|
||||
!windows.some((o) => o.item_id === config.id && o.source === w.source)
|
||||
)
|
||||
.map((w) => ({ ...w, anyPhase: w.item_id !== config.id })),
|
||||
fx: fx ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export { wearOrder, wearShort };
|
||||
@@ -1,5 +1,13 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
check,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { aiSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
|
||||
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
|
||||
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
|
||||
* like a secret, and nothing here is ever a key value.
|
||||
*/
|
||||
export const AI_SETUP_SECTIONS = [
|
||||
'connections',
|
||||
'skills',
|
||||
'instructions',
|
||||
'plugins',
|
||||
'routines',
|
||||
'agents',
|
||||
'plan',
|
||||
'notes'
|
||||
] as const;
|
||||
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
|
||||
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
|
||||
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
|
||||
export const AI_SETUP_COLORS = [
|
||||
'orange',
|
||||
'green',
|
||||
'blue',
|
||||
'violet',
|
||||
'pink',
|
||||
'sky',
|
||||
'amber',
|
||||
'zinc'
|
||||
] as const;
|
||||
|
||||
export const aiSetupProviders = aiSchema.table(
|
||||
'provider_setup_providers',
|
||||
{
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
/** The products this card covers, e.g. "claude.ai + Claude Code". */
|
||||
products: text('products'),
|
||||
color: text('color').notNull().default('zinc'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('provider_setup_providers_name_key').on(t.name),
|
||||
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||
check(
|
||||
'provider_setup_providers_color_check',
|
||||
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiSetupEntries = aiSchema.table(
|
||||
'provider_setup_entries',
|
||||
{
|
||||
id: id(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
section: text('section').notNull(),
|
||||
name: text('name').notNull(),
|
||||
note: text('note'),
|
||||
scope: text('scope'),
|
||||
link: text('link'),
|
||||
/** Free markdown text (custom instructions, longer notes). */
|
||||
body: text('body'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
|
||||
check(
|
||||
'provider_setup_entries_section_check',
|
||||
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
|
||||
),
|
||||
check(
|
||||
'provider_setup_entries_scope_check',
|
||||
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
|
||||
),
|
||||
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -5,11 +5,45 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
bigint
|
||||
bigint,
|
||||
boolean,
|
||||
date,
|
||||
real,
|
||||
smallint,
|
||||
numeric
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { csSchema } from './schemas';
|
||||
import { id } from './helpers';
|
||||
|
||||
// One finish or item, grouping all of its market configs (wear, StatTrak, Souvenir, phase).
|
||||
// Ids come from ByMykel/CSGO-API (skin_id / item id); items only seen in price feeds get `mkt-<slug>`.
|
||||
export const csSkins = csSchema.table(
|
||||
'skins',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
weapon: text('weapon'),
|
||||
pattern: text('pattern'),
|
||||
category: text('category'),
|
||||
rarity: text('rarity'),
|
||||
rarityColor: text('rarity_color'),
|
||||
image: text('image'),
|
||||
minFloat: real('min_float'),
|
||||
maxFloat: real('max_float'),
|
||||
collections: text('collections').array(),
|
||||
source: text('source').notNull().default('bymykel'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('skins_slug_idx').on(table.slug),
|
||||
index('skins_name_idx').on(table.name),
|
||||
index('skins_kind_idx').on(table.kind)
|
||||
]
|
||||
);
|
||||
|
||||
// A Steam market hash name identifies a listing, including wear and special variants.
|
||||
export const csMarketItems = csSchema.table(
|
||||
'items',
|
||||
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
|
||||
name: text('name').notNull(),
|
||||
iconUrl: text('icon_url'),
|
||||
itemType: text('item_type'),
|
||||
// Catalog link and config facets. Doppler phases share a market hash name, so a phase
|
||||
// row is its own config next to the phase-less listing.
|
||||
skinId: text('skin_id').references(() => csSkins.id, { onDelete: 'set null' }),
|
||||
phase: text('phase'),
|
||||
wear: text('wear'),
|
||||
stattrak: boolean('stattrak').notNull().default(false),
|
||||
souvenir: boolean('souvenir').notNull().default(false),
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('items_hash_idx').on(table.marketHashName),
|
||||
index('items_name_idx').on(table.name)
|
||||
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
|
||||
index('items_name_idx').on(table.name),
|
||||
index('items_skin_idx').on(table.skinId)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -82,9 +124,82 @@ export const csPriceStreams = csSchema.table(
|
||||
{
|
||||
source: text('source').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
kind: text('kind').notNull()
|
||||
kind: text('kind').notNull(),
|
||||
id: smallint('id').generatedByDefaultAsIdentity(),
|
||||
label: text('label'),
|
||||
lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }),
|
||||
lastRows: integer('last_rows'),
|
||||
lastError: text('last_error')
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
|
||||
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] }),
|
||||
uniqueIndex('price_streams_id_idx').on(table.id)
|
||||
]
|
||||
);
|
||||
|
||||
// One price per config per stream per UTC day (idempotent upsert). Prices stay in the stream's
|
||||
// own currency; fx_rates converts USD streams to EUR for display.
|
||||
export const csDailyPrices = csSchema.table(
|
||||
'daily_prices',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
streamId: smallint('stream_id').notNull(),
|
||||
day: date('day').notNull(),
|
||||
priceCents: integer('price_cents').notNull(),
|
||||
volume: integer('volume')
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ name: 'daily_prices_pkey', columns: [table.itemId, table.streamId, table.day] }),
|
||||
index('daily_prices_day_idx').on(table.day, table.streamId)
|
||||
]
|
||||
);
|
||||
|
||||
// Rolling sale aggregates (24h/7d/30d/90d) as reported by the source, latest snapshot only.
|
||||
export const csPriceWindows = csSchema.table(
|
||||
'price_windows',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
source: text('source').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
span: text('span').notNull(),
|
||||
medianCents: integer('median_cents'),
|
||||
avgCents: integer('avg_cents'),
|
||||
minCents: integer('min_cents'),
|
||||
maxCents: integer('max_cents'),
|
||||
volume: integer('volume'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
name: 'price_windows_pkey',
|
||||
columns: [table.itemId, table.source, table.span]
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
export const csFxRates = csSchema.table('fx_rates', {
|
||||
day: date('day').primaryKey(),
|
||||
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
|
||||
});
|
||||
|
||||
// Backfill bookkeeping (scripts/cs-history.ts): which config was fetched from which history
|
||||
// source, so the slow, throttled backfill can resume. History rows themselves live in
|
||||
// daily_prices under their own streams (kind ending in `-history`).
|
||||
export const csHistoryFetches = csSchema.table(
|
||||
'history_fetches',
|
||||
{
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||
source: text('source').notNull(),
|
||||
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
points: integer('points').notNull().default(0),
|
||||
firstDay: date('first_day'),
|
||||
error: text('error')
|
||||
},
|
||||
(table) => [primaryKey({ name: 'history_fetches_pkey', columns: [table.itemId, table.source] })]
|
||||
);
|
||||
|
||||
@@ -33,3 +33,6 @@ export * from './movies';
|
||||
export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
export * from './radio';
|
||||
|
||||
112
src/lib/server/db/schema/math.ts
Normal file
112
src/lib/server/db/schema/math.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { mathSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Math tracker (/trackers/math): working through Evan Chen's "An Infinitely Large Napkin".
|
||||
// Parts and chapters (with sections) are seeded from the book's outline in the migration.
|
||||
// Progress lives on the chapter rows; triggers (in the migration) keep started_at/completed_at
|
||||
// and log every status / exercise / note / section change to `activity`. Bots and scripts
|
||||
// update progress with `select math.apply_progress('{"chapter":"3","status":"done"}')` or a
|
||||
// plain UPDATE (both are logged). Prereqs are hints only, never locks.
|
||||
|
||||
// Statuses (kept in sync with MATH_STATUSES in #lib/math; the schema folder must not import
|
||||
// from outside it): not_started, reading, exercises, done, skipped.
|
||||
|
||||
export const mathParts = mathSchema.table(
|
||||
'parts',
|
||||
{
|
||||
id: id(),
|
||||
/** "1".."22" or "appendix". */
|
||||
key: text('key').notNull(),
|
||||
/** Part number; null for the appendix. */
|
||||
num: integer('num'),
|
||||
title: text('title').notNull(),
|
||||
sortOrder: integer('sort_order').notNull()
|
||||
},
|
||||
(t) => [uniqueIndex('parts_key_key').on(t.key)]
|
||||
);
|
||||
|
||||
export const mathChapters = mathSchema.table(
|
||||
'chapters',
|
||||
{
|
||||
id: id(),
|
||||
/** Chapter number as text ("0".."94") or the appendix letter ("A".."E"). */
|
||||
key: text('key').notNull(),
|
||||
partId: integer('part_id')
|
||||
.notNull()
|
||||
.references(() => mathParts.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
/** PDF pages of the free Napkin.pdf (link with #page=N). */
|
||||
pageStart: integer('page_start').notNull(),
|
||||
pageEnd: integer('page_end').notNull(),
|
||||
/** Problems at the end of the chapter; null when the chapter has none. */
|
||||
exerciseCount: integer('exercise_count'),
|
||||
/** Chapter keys this one builds on (approximate, from the book's dependency chart). */
|
||||
prereqs: text('prereqs')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
status: text('status').notNull().default('not_started'),
|
||||
exercisesDone: integer('exercises_done').notNull().default(0),
|
||||
notes: text('notes'),
|
||||
startedAt: timestamptz('started_at'),
|
||||
completedAt: timestamptz('completed_at'),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('chapters_key_key').on(t.key),
|
||||
index('chapters_part_idx').on(t.partId, t.sortOrder),
|
||||
check(
|
||||
'chapters_status_check',
|
||||
sql`${t.status} IN ('not_started','reading','exercises','done','skipped')`
|
||||
),
|
||||
check('chapters_exercises_done_check', sql`${t.exercisesDone} >= 0`)
|
||||
]
|
||||
);
|
||||
|
||||
export const mathSections = mathSchema.table(
|
||||
'sections',
|
||||
{
|
||||
id: id(),
|
||||
chapterId: integer('chapter_id')
|
||||
.notNull()
|
||||
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** Section number, e.g. "3.2" (or "D.1"). */
|
||||
key: text('key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
/** Optional per-section tick. */
|
||||
doneAt: timestamptz('done_at')
|
||||
},
|
||||
(t) => [uniqueIndex('sections_chapter_key_key').on(t.chapterId, t.key)]
|
||||
);
|
||||
|
||||
export const mathActivity = mathSchema.table(
|
||||
'activity',
|
||||
{
|
||||
id: id(),
|
||||
chapterId: integer('chapter_id')
|
||||
.notNull()
|
||||
.references(() => mathChapters.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
sectionId: integer('section_id').references(() => mathSections.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
/** status | exercises | note | section */
|
||||
kind: text('kind').notNull(),
|
||||
fromValue: text('from_value'),
|
||||
toValue: text('to_value'),
|
||||
/** exercises: done count change. */
|
||||
delta: integer('delta'),
|
||||
/** site | bot | sql (from the math.source setting, see math.apply_progress). */
|
||||
source: text('source').notNull().default('sql'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
index('activity_created_idx').on(t.createdAt),
|
||||
index('activity_chapter_idx').on(t.chapterId, t.createdAt),
|
||||
check('activity_kind_check', sql`${t.kind} IN ('status','exercises','note','section')`)
|
||||
]
|
||||
);
|
||||
118
src/lib/server/db/schema/plant.ts
Normal file
118
src/lib/server/db/schema/plant.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex,
|
||||
type AnyPgColumn
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { plantSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): what the Cargill Bioindustrial plant
|
||||
// does. One generic `entries` table (departments, products, process steps, equipment, glossary,
|
||||
// questions, notes, roles) plus typed `links` between them. Bots apply JSON with
|
||||
// `select plant.apply_entries('{...}'::jsonb)` (shape documented in the migration and in
|
||||
// docs/plant-tracker.md). Kinds, confidences and relations are kept in sync with #lib/plant.
|
||||
|
||||
export const plantEntries = plantSchema.table(
|
||||
'entries',
|
||||
{
|
||||
id: id(),
|
||||
/** process_step | product | department | equipment | glossary | question | note | person_role */
|
||||
kind: text('kind').notNull(),
|
||||
/** Unique per kind; used in URLs and by apply_entries. */
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
/** Markdown. */
|
||||
body: text('body'),
|
||||
parentId: integer('parent_id').references((): AnyPgColumn => plantEntries.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
tags: text('tags')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
/** Where it came from, e.g. "page 2026-10-26", "Walter White", "Stan". */
|
||||
source: text('source'),
|
||||
/** confirmed (checked at work) | assumed (from the guide) | open (unknown). */
|
||||
confidence: text('confidence').notNull().default('assumed'),
|
||||
/** Questions: when it was answered (null = still open). */
|
||||
answeredAt: timestamptz('answered_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('entries_kind_slug_key').on(t.kind, t.slug),
|
||||
index('entries_parent_idx').on(t.parentId),
|
||||
check(
|
||||
'entries_kind_check',
|
||||
sql`${t.kind} IN ('process_step','product','department','equipment','glossary','question','note','person_role')`
|
||||
),
|
||||
check('entries_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
check('entries_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`)
|
||||
]
|
||||
);
|
||||
|
||||
export const plantLinks = plantSchema.table(
|
||||
'links',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
toId: integer('to_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** feeds_into | made_in | uses | part_of | related */
|
||||
relation: text('relation').notNull(),
|
||||
/** Short edge label, e.g. "fatty acids". */
|
||||
label: text('label'),
|
||||
/** Drawn dashed in the flow (optional path or a loop back); doesn't push layers down. */
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
/** confirmed | assumed | open; not-confirmed links are drawn dashed with a "?". */
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
check('links_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
uniqueIndex('links_from_to_relation_key').on(t.fromId, t.toId, t.relation),
|
||||
index('links_to_idx').on(t.toId),
|
||||
check(
|
||||
'links_relation_check',
|
||||
sql`${t.relation} IN ('feeds_into','made_in','uses','part_of','related')`
|
||||
),
|
||||
check('links_not_self_check', sql`${t.fromId} <> ${t.toId}`)
|
||||
]
|
||||
);
|
||||
|
||||
/** Parents and links whose target isn't in the tracker yet. apply_entries retries them on every
|
||||
* run, so a later batch that adds the target resolves them; the detail page lists them as pending. */
|
||||
export const plantPending = plantSchema.table(
|
||||
'pending',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** "parent" or a link relation. */
|
||||
field: text('field').notNull(),
|
||||
/** The reference as given ("slug" or "kind:slug"). */
|
||||
toRef: text('to_ref').notNull(),
|
||||
label: text('label'),
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('pending_from_field_ref_key').on(t.fromId, t.field, t.toRef),
|
||||
check(
|
||||
'pending_field_check',
|
||||
sql`${t.field} IN ('parent','feeds_into','made_in','uses','part_of','related')`
|
||||
)
|
||||
]
|
||||
);
|
||||
186
src/lib/server/db/schema/radio.ts
Normal file
186
src/lib/server/db/schema/radio.ts
Normal 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')`)
|
||||
]
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -25,6 +25,7 @@ const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as co
|
||||
const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
|
||||
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||
const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
|
||||
|
||||
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
|
||||
|
||||
@@ -36,6 +37,11 @@ export const worldtodoRequestStatus = worldtodoSchema.enum(
|
||||
WORLDTODO_REQUEST_STATUSES
|
||||
);
|
||||
|
||||
export const worldtodoLocationPrecision = worldtodoSchema.enum(
|
||||
'location_precision',
|
||||
WORLDTODO_LOCATION_PRECISIONS
|
||||
);
|
||||
|
||||
export const worldtodoCategories = worldtodoSchema.table('categories', {
|
||||
slug: text('slug').primaryKey(),
|
||||
label: text('label').notNull(),
|
||||
@@ -61,6 +67,13 @@ export const worldtodoTasks = worldtodoSchema.table(
|
||||
countryCode: text('country_code'),
|
||||
lat: doublePrecision('lat'),
|
||||
lon: doublePrecision('lon'),
|
||||
/**
|
||||
* How the map treats lat/lon: street and city tasks are exact pins, region and country
|
||||
* pins sit at a rough centre, planet tasks (or no coordinates) get no pin. Set from scale
|
||||
* and lat/lon by the tasks_location_precision trigger (hand-written in the migration), so
|
||||
* it never drifts from the data.
|
||||
*/
|
||||
locationPrecision: worldtodoLocationPrecision('location_precision'),
|
||||
status: worldtodoStatus('status').notNull().default('open'),
|
||||
sourceUrls: text('source_urls').array().notNull(),
|
||||
/** Date of the main source as given ('2026-09', '2026-10-21'). */
|
||||
|
||||
@@ -48,7 +48,11 @@ export type BankTransaction = {
|
||||
creditor_account?: { iban?: string | null } | null;
|
||||
debtor_account?: { iban?: string | null } | null;
|
||||
remittance_information?: string[] | null;
|
||||
bank_transaction_code?: { description?: string | null } | null;
|
||||
bank_transaction_code?: {
|
||||
code?: string | null;
|
||||
sub_code?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
merchant_category_code?: string | null;
|
||||
note?: string | null;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -23,6 +23,10 @@ export type GoalMetricDetail = {
|
||||
export type GoalMetricResult = { value: number; detail: GoalMetricDetail };
|
||||
|
||||
export const SIDE_INCOME_SOURCE = 'moneybird-side-income-t12m';
|
||||
/** money.transactions.category values that count as investment income (agreed with MoneyMan).
|
||||
* Dividends, interest (incl. Revolut savings interest) and distributions are recorded as one
|
||||
* of these; deposits, transfers, investment sales and unrealised gains are NOT. */
|
||||
export const INVESTMENT_INCOME_CATEGORIES = ['dividend', 'interest'];
|
||||
const TZ = 'Europe/Amsterdam';
|
||||
|
||||
type SourceRow = {
|
||||
@@ -54,6 +58,18 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
||||
select value, detail, computed_at, error from tasks.metric_sources
|
||||
where key = ${SIDE_INCOME_SOURCE}`;
|
||||
|
||||
// Investment income: trailing 12 months of money.transactions with amount_cents > 0 and
|
||||
// category in INVESTMENT_INCOME_CATEGORIES, on any account except Joint (shared), ÷ 12.
|
||||
const [inv] = await sql<{ cents: string; n: number }[]>`
|
||||
select coalesce(sum(t.amount_cents), 0) as cents, count(*)::int as n
|
||||
from money.transactions t
|
||||
join money.accounts a on a.id = t.account_id
|
||||
where t.amount_cents > 0
|
||||
and t.category = any(${INVESTMENT_INCOME_CATEGORIES})
|
||||
and a.scope <> 'joint'
|
||||
and t.date > ((now() at time zone ${TZ})::date - interval '12 months')
|
||||
and t.date <= (now() at time zone ${TZ})::date`;
|
||||
|
||||
const parts: GoalMetricPart[] = rows.map((r) => ({
|
||||
key: `recurring-${r.id}`,
|
||||
label: r.name,
|
||||
@@ -71,6 +87,12 @@ async function ownNetIncomeMonthly(sql: Sql): Promise<GoalMetricResult> {
|
||||
: 'not fetched yet',
|
||||
asOf: src?.computed_at ? new Date(src.computed_at).toISOString() : null
|
||||
});
|
||||
parts.push({
|
||||
key: 'investment-income',
|
||||
label: 'Investment income',
|
||||
value: round2(Number(inv?.cents ?? 0) / 100 / 12),
|
||||
note: `${inv?.n ?? 0} dividend/interest payments, last 12 months ÷ 12`
|
||||
});
|
||||
return {
|
||||
value: round2(parts.reduce((s, p) => s + p.value, 0)),
|
||||
detail: {
|
||||
@@ -87,7 +109,7 @@ export const GOAL_METRICS: Record<
|
||||
{ label: string; compute: (sql: Sql) => Promise<GoalMetricResult> }
|
||||
> = {
|
||||
'own-net-income-monthly': {
|
||||
label: 'Own net income / month (salary + side income)',
|
||||
label: 'Own net income / month (salary + side + investment income)',
|
||||
compute: ownNetIncomeMonthly
|
||||
}
|
||||
};
|
||||
|
||||
41
src/lib/server/investment-income.test.ts
Normal file
41
src/lib/server/investment-income.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { investmentIncomeCategory as cat } from './investment-income';
|
||||
|
||||
const pos = (texts: string[], kind?: string) => cat({ amountCents: 123, kind, texts });
|
||||
|
||||
describe('investment income tagging', () => {
|
||||
it('tags interest payouts from real and Revolut wording', () => {
|
||||
for (const text of [
|
||||
'Interest',
|
||||
'Knab: Rente',
|
||||
'Interest PAID EUR Class R IE000AZVL3K0',
|
||||
'Savings Interest',
|
||||
'Savings vault interest',
|
||||
'Gross interest',
|
||||
'Net interest',
|
||||
'Interest earned',
|
||||
'Rentebijschrijving'
|
||||
])
|
||||
expect(pos([text]), text).toBe('interest');
|
||||
expect(pos(['Revolut'], 'Interest')).toBe('interest');
|
||||
expect(pos(['Revolut', 'Interest earned on your Savings'], 'TRANSFER')).toBe('interest');
|
||||
});
|
||||
it('tags dividend payouts', () => {
|
||||
expect(pos(['Dividend Payment: GOOGL'])).toBe('dividend');
|
||||
expect(pos(['VWRL dividend'])).toBe('dividend');
|
||||
expect(pos(['Dividenduitkering'])).toBe('dividend');
|
||||
expect(pos(['Broker'], 'DIVIDEND')).toBe('dividend');
|
||||
});
|
||||
it('ignores costs, reinvestments, card rows and look-alikes', () => {
|
||||
expect(cat({ amountCents: -1043, texts: ['Interest Reinvested Class R EUR'] })).toBeNull();
|
||||
expect(cat({ amountCents: -2, texts: ['Service Fee Charged EUR Class'] })).toBeNull();
|
||||
expect(cat({ amountCents: -500, texts: ['Dividend tax'] })).toBeNull();
|
||||
expect(cat({ amountCents: 0, texts: ['Interest'] })).toBeNull();
|
||||
expect(pos(['Interest Cafe'], 'Card Refund')).toBeNull();
|
||||
expect(pos(['Interest Cafe Utrecht'])).toBeNull();
|
||||
expect(pos(['From Juhee'])).toBeNull();
|
||||
expect(pos(['Tikkie: Rente lening terug'])).toBeNull();
|
||||
expect(pos(['Rentenaar BV'])).toBeNull();
|
||||
expect(pos(['Salaris Cargill'])).toBeNull();
|
||||
});
|
||||
});
|
||||
49
src/lib/server/investment-income.ts
Normal file
49
src/lib/server/investment-income.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tags investment income on import so the €10k/month goal bar (goal-metrics/core.ts,
|
||||
// INVESTMENT_INCOME_CATEGORIES) can find it: positive interest payouts -> 'interest',
|
||||
// positive dividend payouts -> 'dividend'. Pure, shared by the bank sync and statement imports.
|
||||
|
||||
export type IncomeCategory = 'interest' | 'dividend';
|
||||
|
||||
/** Revolut "Type" / bank transaction codes that are never income payouts. */
|
||||
const NOT_INCOME_KIND = /^(card[ _-]?(payment|refund)|exchange|atm|fee|charge)$/i;
|
||||
|
||||
const DIVIDEND = /\bdividend(?:s|en|uitkering)?\b/i;
|
||||
|
||||
/**
|
||||
* Interest wording seen in real rows and Revolut exports:
|
||||
* "Interest" (ABN), "Knab: Rente", Revolut "Interest PAID EUR Class R IE000…",
|
||||
* "Savings Interest", "Savings vault interest", "Gross/Net interest", "Rentebijschrijving".
|
||||
* Payout-only: "Interest Reinvested" / "Service Fee Charged" are negative and so never match.
|
||||
*/
|
||||
const INTEREST = [
|
||||
// Whole description is (a bank prefix plus) the word interest/rente, optionally qualified.
|
||||
/^(?:[\p{L}0-9 .&'-]{1,30}:\s*)?(?:(?:gross|net|savings|vault|credit)\s+)?(?:interest|rente|creditrente)(?:\s+(?:paid|earned|payout|payment|credited|bijschrijving|uitbetaling)\b.*)?$/iu,
|
||||
/^interest\s+paid\b/i,
|
||||
/\b(?:savings|vault|pocket|spaar)\s*(?:account\s+)?interest\b/i,
|
||||
/\binterest\s+(?:earned|payout|credited)\b/i,
|
||||
/\brente(?:bijschrijving|uitkering|vergoeding)\b/i
|
||||
];
|
||||
|
||||
export type IncomeInput = {
|
||||
amountCents: number;
|
||||
/** Revolut statement "Type" or the bank's transaction code. */
|
||||
kind?: string | null;
|
||||
/** Description, remittance lines, bank code description, note… */
|
||||
texts: (string | null | undefined)[];
|
||||
};
|
||||
|
||||
export function investmentIncomeCategory({
|
||||
amountCents,
|
||||
kind,
|
||||
texts
|
||||
}: IncomeInput): IncomeCategory | null {
|
||||
if (!(amountCents > 0)) return null;
|
||||
const k = kind?.trim() ?? '';
|
||||
if (k && NOT_INCOME_KIND.test(k)) return null;
|
||||
if (DIVIDEND.test(k)) return 'dividend';
|
||||
if (/\binterest\b/i.test(k)) return 'interest';
|
||||
const lines = texts.map((t) => t?.replace(/\s+/g, ' ').trim()).filter((t): t is string => !!t);
|
||||
if (lines.some((t) => DIVIDEND.test(t))) return 'dividend';
|
||||
if (lines.some((t) => INTEREST.some((re) => re.test(t)))) return 'interest';
|
||||
return null;
|
||||
}
|
||||
134
src/lib/server/math.ts
Normal file
134
src/lib/server/math.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Math tracker (/trackers/math): loads the Napkin outline with progress, and applies progress
|
||||
// updates through math.apply_progress (the same JSON format bots use, see the migration).
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { MATH_STATUSES, type MathStatus } from '#lib/math.js';
|
||||
import * as z from 'zod';
|
||||
|
||||
export type MathSection = { id: number; key: string; title: string; done: boolean };
|
||||
export type MathChapter = {
|
||||
id: number;
|
||||
key: string;
|
||||
partKey: string;
|
||||
title: string;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
exerciseCount: number | null;
|
||||
prereqs: string[];
|
||||
status: MathStatus;
|
||||
exercisesDone: number;
|
||||
notes: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
updatedAt: string;
|
||||
sections: MathSection[];
|
||||
};
|
||||
export type MathPart = { key: string; num: number | null; title: string };
|
||||
export type MathActivity = {
|
||||
id: number;
|
||||
chapterKey: string;
|
||||
chapterTitle: string;
|
||||
sectionKey: string | null;
|
||||
kind: 'status' | 'exercises' | 'note' | 'section';
|
||||
fromValue: string | null;
|
||||
toValue: string | null;
|
||||
delta: number | null;
|
||||
source: string;
|
||||
at: string;
|
||||
};
|
||||
|
||||
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
|
||||
|
||||
export async function loadMath(owner: boolean) {
|
||||
const [parts, chapters, sections, activity] = await Promise.all([
|
||||
client`SELECT key, num, title FROM math.parts ORDER BY sort_order`,
|
||||
client`SELECT c.id, c.key, p.key AS part_key, c.title, c.page_start, c.page_end,
|
||||
c.exercise_count, c.prereqs, c.status, c.exercises_done, c.notes,
|
||||
c.started_at, c.completed_at, c.updated_at
|
||||
FROM math.chapters c JOIN math.parts p ON p.id = c.part_id
|
||||
ORDER BY c.sort_order`,
|
||||
client`SELECT id, chapter_id, key, title, done_at FROM math.sections
|
||||
ORDER BY chapter_id, sort_order`,
|
||||
client`SELECT a.id, c.key AS chapter_key, c.title AS chapter_title, s.key AS section_key,
|
||||
a.kind, a.from_value, a.to_value, a.delta, a.source, a.created_at
|
||||
FROM math.activity a
|
||||
JOIN math.chapters c ON c.id = a.chapter_id
|
||||
LEFT JOIN math.sections s ON s.id = a.section_id
|
||||
ORDER BY a.created_at DESC, a.id DESC LIMIT 25`
|
||||
]);
|
||||
const byChapter = new Map<number, MathSection[]>();
|
||||
for (const s of sections) {
|
||||
const list = byChapter.get(s.chapter_id) ?? [];
|
||||
list.push({ id: s.id, key: s.key, title: s.title, done: !!s.done_at });
|
||||
byChapter.set(s.chapter_id, list);
|
||||
}
|
||||
return {
|
||||
parts: parts.map((p) => ({ key: p.key, num: p.num, title: p.title }) as MathPart),
|
||||
chapters: chapters.map((c): MathChapter => ({
|
||||
id: c.id,
|
||||
key: c.key,
|
||||
partKey: c.part_key,
|
||||
title: c.title,
|
||||
pageStart: c.page_start,
|
||||
pageEnd: c.page_end,
|
||||
exerciseCount: c.exercise_count,
|
||||
prereqs: c.prereqs ?? [],
|
||||
status: c.status,
|
||||
exercisesDone: c.exercises_done,
|
||||
notes: owner ? c.notes : null,
|
||||
startedAt: iso(c.started_at),
|
||||
completedAt: iso(c.completed_at),
|
||||
updatedAt: iso(c.updated_at)!,
|
||||
sections: byChapter.get(c.id) ?? []
|
||||
})),
|
||||
activity: activity
|
||||
.filter((a) => owner || a.kind !== 'note')
|
||||
.map((a): MathActivity => ({
|
||||
id: a.id,
|
||||
chapterKey: a.chapter_key,
|
||||
chapterTitle: a.chapter_title,
|
||||
sectionKey: a.section_key,
|
||||
kind: a.kind,
|
||||
fromValue: a.kind === 'note' && !owner ? null : a.from_value,
|
||||
toValue: a.kind === 'note' && !owner ? null : a.to_value,
|
||||
delta: a.delta,
|
||||
source: a.source,
|
||||
at: iso(a.created_at)!
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const progressInput = z
|
||||
.object({
|
||||
chapter: z.string().trim().min(1).max(4),
|
||||
status: z.enum(MATH_STATUSES).optional(),
|
||||
exercises_add: z.coerce.number().int().min(-50).max(50).optional(),
|
||||
exercises_done: z.coerce.number().int().min(0).max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
section: z.string().trim().max(12).optional(),
|
||||
section_done: z.enum(['1', '0']).optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** One owner update from the page (form fields), applied as a math.apply_progress JSON. */
|
||||
export async function applyFromForm(form: FormData) {
|
||||
const raw: Record<string, string> = {};
|
||||
for (const [k, v] of form) if (typeof v === 'string' && v !== '') raw[k] = v;
|
||||
if (form.has('notes')) raw.notes = String(form.get('notes') ?? '');
|
||||
const parsed = progressInput.safeParse(raw);
|
||||
if (!parsed.success) return { ok: false as const, message: 'Invalid update' };
|
||||
const p = parsed.data;
|
||||
const json: Record<string, unknown> = { chapter: p.chapter, source: 'site' };
|
||||
if (p.status) json.status = p.status;
|
||||
if (p.exercises_add !== undefined) json.exercises_add = p.exercises_add;
|
||||
if (p.exercises_done !== undefined) json.exercises_done = p.exercises_done;
|
||||
if (p.notes !== undefined) json.notes = p.notes;
|
||||
if (p.section) json[p.section_done === '0' ? 'sections_undone' : 'sections_done'] = [p.section];
|
||||
try {
|
||||
await client`SELECT math.apply_progress(${JSON.stringify(json)}::jsonb)`;
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (msg.includes('unknown chapter')) return { ok: false as const, message: 'Unknown chapter' };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
|
||||
import { MATH_PRIVATE } from '#lib/math.js';
|
||||
|
||||
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
|
||||
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
|
||||
@@ -37,6 +38,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);
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
|
||||
27
src/lib/server/plant-actions.ts
Normal file
27
src/lib/server/plant-actions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Form actions shared by the /trackers/cargill overview and entry pages. Actions don't run the
|
||||
// load, so each one checks the owner again (hooks.server.ts also 404s the whole path).
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { addLink, answerQuestion, deleteEntry, removeLink, saveEntry } from '#lib/server/plant.js';
|
||||
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
type Handler = (
|
||||
form: FormData
|
||||
) => Promise<{ ok: true; href?: string } | { ok: false; message: string }>;
|
||||
|
||||
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const r = await fn(form);
|
||||
if (!r.ok) return fail(400, { message: r.message });
|
||||
// Deleting from an entry's own page goes back to the overview.
|
||||
if (form.get('redirect') === '1' && r.href) redirect(303, r.href);
|
||||
return { ok: true as const, href: r.href };
|
||||
};
|
||||
|
||||
export const plantActions = {
|
||||
save: action(saveEntry),
|
||||
delete: action(deleteEntry),
|
||||
answer: action(answerQuestion),
|
||||
link: action(addLink),
|
||||
unlink: action(removeLink)
|
||||
};
|
||||
232
src/lib/server/plant.ts
Normal file
232
src/lib/server/plant.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): loads entries and links, renders
|
||||
// markdown bodies, and applies owner edits. Every write goes through plant.apply_entries (the
|
||||
// same JSON format bots use, see the plant migration) or a plain delete, so the site and bots
|
||||
// share one code path.
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import {
|
||||
CONFIDENCES,
|
||||
PLANT_KINDS,
|
||||
RELATIONS,
|
||||
slugify,
|
||||
type Confidence,
|
||||
type PlantKind,
|
||||
type Relation
|
||||
} from '#lib/plant.js';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown bodies. Raw HTML is shown as text, never rendered; only http(s)/mailto/relative links.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
const ext = /^(https?:|mailto:)/i.test(href)
|
||||
? ' rel="noopener noreferrer" target="_blank"'
|
||||
: '';
|
||||
return `<a href="${escapeHtml(href)}"${t}${ext}>${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
export const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
export type PlantEntry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: Confidence;
|
||||
answeredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type PlantLink = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: Relation;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
/** Not-confirmed links show dashed with a "?". */
|
||||
confidence: Confidence;
|
||||
};
|
||||
|
||||
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
|
||||
|
||||
export async function loadPlant() {
|
||||
const [entries, links] = await Promise.all([
|
||||
client`SELECT id, kind, slug, title, summary, body, parent_id, tags, sort_order, source,
|
||||
confidence, answered_at, created_at, updated_at
|
||||
FROM plant.entries ORDER BY kind, sort_order, lower(title)`,
|
||||
client`SELECT id, from_id, to_id, relation, label, dashed, confidence
|
||||
FROM plant.links ORDER BY id`
|
||||
]);
|
||||
return {
|
||||
entries: entries.map((e): PlantEntry => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
slug: e.slug,
|
||||
title: e.title,
|
||||
summary: e.summary,
|
||||
body: e.body,
|
||||
parentId: e.parent_id,
|
||||
tags: e.tags ?? [],
|
||||
sortOrder: e.sort_order,
|
||||
source: e.source,
|
||||
confidence: e.confidence,
|
||||
answeredAt: iso(e.answered_at),
|
||||
createdAt: iso(e.created_at)!,
|
||||
updatedAt: iso(e.updated_at)!
|
||||
})),
|
||||
links: links.map((l): PlantLink => ({
|
||||
id: l.id,
|
||||
fromId: l.from_id,
|
||||
toId: l.to_id,
|
||||
relation: l.relation,
|
||||
label: l.label,
|
||||
dashed: l.dashed,
|
||||
confidence: l.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
type Result = { ok: true; href?: string } | { ok: false; message: string };
|
||||
|
||||
const text = (max: number) => z.string().trim().max(max);
|
||||
const entryInput = z.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
kind: z.enum(PLANT_KINDS),
|
||||
title: text(200).min(1, 'A title is required'),
|
||||
slug: text(80).optional(),
|
||||
summary: text(500).optional(),
|
||||
body: z.string().max(50_000).optional(),
|
||||
tags: text(400).optional(),
|
||||
parent_id: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
|
||||
sort_order: z.union([z.literal(''), z.coerce.number().int().min(-100000).max(100000)]).optional(),
|
||||
source: text(200).optional(),
|
||||
confidence: z.enum(CONFIDENCES)
|
||||
});
|
||||
|
||||
const formObject = (form: FormData) => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
|
||||
return o;
|
||||
};
|
||||
const dbMessage = (e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('entries_kind_slug_key')) return 'That slug is already used for this kind';
|
||||
if (msg.startsWith('plant')) return msg.replace(/^plant(\.apply_entries)?: /, '');
|
||||
throw e;
|
||||
};
|
||||
|
||||
/** Create (no id) or update (id) one entry from the owner form. */
|
||||
export async function saveEntry(form: FormData): Promise<Result> {
|
||||
const parsed = entryInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid' };
|
||||
const p = parsed.data;
|
||||
const slug = slugify(p.slug || p.title);
|
||||
if (!slug) return { ok: false, message: 'Use some letters or digits in the title or slug' };
|
||||
const tags = (p.tags ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
const parentId = typeof p.parent_id === 'number' ? p.parent_id : null;
|
||||
if (parentId && parentId === p.id)
|
||||
return { ok: false, message: 'An entry cannot be its own parent' };
|
||||
try {
|
||||
if (p.id) {
|
||||
// Updates go straight to the row (kind and slug may change, which apply_entries keys on).
|
||||
const rows = await client`UPDATE plant.entries SET
|
||||
kind = ${p.kind}, slug = ${slug}, title = ${p.title},
|
||||
summary = ${p.summary || null}, body = ${p.body?.trim() || null},
|
||||
tags = ${tags}, parent_id = ${parentId},
|
||||
sort_order = ${typeof p.sort_order === 'number' ? p.sort_order : 0},
|
||||
source = ${p.source || null}, confidence = ${p.confidence}
|
||||
WHERE id = ${p.id} RETURNING kind, slug`;
|
||||
if (!rows.length) return { ok: false, message: 'That entry no longer exists' };
|
||||
return {
|
||||
ok: true,
|
||||
href: `/trackers/cargill/${rows[0].kind.replace('_', '-')}/${rows[0].slug}`
|
||||
};
|
||||
}
|
||||
const [exists] =
|
||||
await client`SELECT 1 FROM plant.entries WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
if (exists) return { ok: false, message: 'That slug is already used for this kind' };
|
||||
const json: Record<string, unknown> = {
|
||||
kind: p.kind,
|
||||
slug,
|
||||
title: p.title,
|
||||
confidence: p.confidence,
|
||||
source: p.source || 'Stan',
|
||||
tags
|
||||
};
|
||||
if (p.summary) json.summary = p.summary;
|
||||
if (p.body?.trim()) json.body = p.body;
|
||||
if (typeof p.sort_order === 'number') json.sort_order = p.sort_order;
|
||||
await client`SELECT plant.apply_entries(${JSON.stringify({ entries: [json] })}::jsonb)`;
|
||||
if (parentId)
|
||||
await client`UPDATE plant.entries SET parent_id = ${parentId} WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
return { ok: true, href: `/trackers/cargill/${p.kind.replace('_', '-')}/${slug}` };
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
const idOnly = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid entry' };
|
||||
await client`DELETE FROM plant.entries WHERE id = ${p.data.id}`;
|
||||
return { ok: true, href: '/trackers/cargill' };
|
||||
}
|
||||
|
||||
/** Questions: toggle answered (answered=1 marks it, 0 reopens). */
|
||||
export async function answerQuestion(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid question' };
|
||||
const on = form.get('answered') === '1';
|
||||
await client`UPDATE plant.entries
|
||||
SET answered_at = CASE WHEN ${on}::boolean THEN coalesce(answered_at, now()) END
|
||||
WHERE id = ${p.data.id} AND kind = 'question'`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const linkInput = z.object({
|
||||
from_id: z.coerce.number().int().positive(),
|
||||
to_id: z.coerce.number().int().positive(),
|
||||
relation: z.enum(RELATIONS),
|
||||
label: text(80).optional(),
|
||||
dashed: z.literal('1').optional()
|
||||
});
|
||||
|
||||
export async function addLink(form: FormData): Promise<Result> {
|
||||
const parsed = linkInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: 'Pick an entry and a relation' };
|
||||
const l = parsed.data;
|
||||
if (l.from_id === l.to_id) return { ok: false, message: 'An entry cannot link to itself' };
|
||||
await client`INSERT INTO plant.links (from_id, to_id, relation, label, dashed)
|
||||
VALUES (${l.from_id}, ${l.to_id}, ${l.relation}, ${l.label || null}, ${l.dashed === '1'})
|
||||
ON CONFLICT (from_id, to_id, relation) DO UPDATE SET label = excluded.label, dashed = excluded.dashed`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeLink(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid link' };
|
||||
await client`DELETE FROM plant.links WHERE id = ${p.data.id}`;
|
||||
return { ok: true };
|
||||
}
|
||||
373
src/lib/server/radio.ts
Normal file
373
src/lib/server/radio.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
statementCategory,
|
||||
checkBalances,
|
||||
parseAmount,
|
||||
parseRevolutCsv,
|
||||
@@ -21,6 +22,19 @@ Card Payment,Current,2025-08-05 10:00:00,2025-08-05 11:00:00,Kiosk,-3.50,0.00,EU
|
||||
`;
|
||||
|
||||
describe('revolut statement', () => {
|
||||
it('tags interest and dividend rows by type or description', () => {
|
||||
const row = (type: string, description: string, amountCents = 512) => ({
|
||||
type,
|
||||
description,
|
||||
amountCents
|
||||
});
|
||||
expect(statementCategory(row('Interest', 'Savings vault interest'))).toBe('interest');
|
||||
expect(statementCategory(row('', 'Interest PAID EUR Class R IE000AZVL3K0'))).toBe('interest');
|
||||
expect(statementCategory(row('Dividend', 'VWRL'))).toBe('dividend');
|
||||
expect(statementCategory(row('Transfer', 'To EUR Savings', -10000))).toBeNull();
|
||||
expect(statementCategory(row('Card Payment', 'Interest Cafe'))).toBeNull();
|
||||
expect(statementCategory(row('Transfer', 'From Juhee'))).toBeNull();
|
||||
});
|
||||
it('parses amounts in both notations', () => {
|
||||
expect(parseAmount('1,234.56')).toBe(123456);
|
||||
expect(parseAmount('-1.234,56')).toBe(-123456);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { investmentIncomeCategory } from './investment-income';
|
||||
|
||||
// Revolut account statement parsing + import planning (pure; DB work is in scripts/revolut-import.ts).
|
||||
// Handles the app's CSV export (English or Dutch headers, , or ; separated), the Excel export
|
||||
// (rows passed in as a string table) and, defensively, the text of the PDF statement.
|
||||
@@ -341,3 +343,12 @@ export function statementExternalId(row: StatementRow, hash: (s: string) => stri
|
||||
).slice(0, 24)
|
||||
);
|
||||
}
|
||||
|
||||
/** 'interest' / 'dividend' for a statement row that pays investment income, else null. */
|
||||
export function statementCategory(row: Pick<StatementRow, 'amountCents' | 'type' | 'description'>) {
|
||||
return investmentIncomeCategory({
|
||||
amountCents: row.amountCents,
|
||||
kind: row.type,
|
||||
texts: [row.description]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
111
src/lib/server/stats-matrix.ts
Normal file
111
src/lib/server/stats-matrix.ts
Normal 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
Reference in New Issue
Block a user