194 Commits

Author SHA1 Message Date
2e8658bf33 money: count real Joint recurring items before the move instead of a flat €900/mo
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-26 07:34:49 +00:00
e33afe5387 notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
Vault files stay the source of truth (bind-mounted read-write at /notes):
atomic writes chowned to the vault owner, hash-checked saves (409 on a
newer file), deletes to .trash. notes.* is a full-text/link index kept
current by a recursive watcher plus a 60s rescan; live updates over the
owner-only /ws/notes channel (paths and hashes only). Wikilinks, embeds,
backlinks, tags, callouts, image paste. Owner-only everywhere (404).
2026-10-26 01:28:57 +00:00
6654c0990a worldwide todo list: zod namespace import (the named import is undefined under bun in the Docker test step)
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-26 01:28:23 +00:00
5e812271e1 worldwide todo list: public todo list of 68 real, sourced problems (planet to street) at /projects/worldwide-todo. worldtodo schema (categories, tasks, change_requests), idempotent seed on deploy, public add/edit/delete requests with Turnstile + salted daily-rotating IP-hash rate limit, owner-only review queue (approve, edit & approve, reject) with live updates over /ws/worldtodo(-owner), JSON API, detail pages with OSM map and history; listed in projects, header, /start and sitemap
Some checks failed
Deploy production / deploy (push) Failing after 9s
2026-10-26 01:26:17 +00:00
0ee981a96b deploy preflight: tasks schema has 5 tables since tasks.metric_sources (goal metrics)
All checks were successful
Deploy production / deploy (push) Successful in 1m12s
2026-10-26 01:23:35 +00:00
d5e2ec346b money: rent detection matches whole words ("living with parents" is not the rent)
Some checks failed
Deploy production / deploy (push) Failing after 1m6s
The scenario move date came from the first Joint item matching /rent|huur/,
which since the "Groceries (living with parents)" item matched that one, so
the scenarios never moved out (no rent, the pre-move contribution forever).
2026-10-26 01:20:39 +00:00
807a6b3104 money: day-by-day forecast with real payment dates
The wealth forecast now runs per day instead of per month, so it shows the
same sawtooth as the actual history:

- recurring items hit on the day the bank history shows (most common day of
  the last four months, clamped to month end), else their start date
- "every N weeks" items (Basic-Fit, haircut) run on an exact N-day cadence
  from the last charge; yearly items on their renewal date
- salary and its extras (holiday pay in May, 13th month in November, bonus,
  settlement) land on the payday from history (contract 25th as fallback),
  moved to the Friday before a weekend; toeslagen move to the Monday after
- variable budgets are spread per day, the vacation budget over Jul-Aug
- excess above the buffer is invested the day after payday; returns
  compound daily
- long horizons stay fast (~35 ms for 100 years): the chart thins each
  pixel bucket to its low and high
2026-10-26 01:20:39 +00:00
5d732fc750 Tasks goals: auto metrics (tasks.goals.metric_key/metric_detail/metric_computed_at, tasks.metric_sources cache). €10k/month goal gets a live progress bar: Revolut recurring income (excl. Zorgtoeslag/Joint) computed on page load + trailing-12m paid Moneybird invoices / 12 cached by a daily goal-metrics user timer; stacked bar with %, amount vs target, salary/side-income breakdown, last-updated
Some checks failed
Deploy production / deploy (push) Failing after 1m14s
2026-10-26 01:17:29 +00:00
5c6ce75409 osu! goals: actually pass my play counts into the refresh (most-played list, weekly or on force)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 01:00:39 +00:00
5e10ecf525 osu! goals: real play counts from my most-played list (weekly), BPM range from the .osu timing points
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-26 00:44:12 +00:00
9f8d54d9e3 osu! goals: games.osu_goals (maps to pass/FC from the old list) with osu! API v2 metadata (stars NM/DT/HR/HD, BPM, length, AR/OD/CS/HP, combo, objects, tags, community tags, streams from .osu), my best scores and pass/FC via hourly in-app refresh; Goals section on /trackers/games/osu; retired list URL 301s there
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-26 00:36:06 +00:00
45841d6f6c stats: core-7 economy page with CPI index levels, rebase and categories
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- /stats/economy defaults multi-country charts to CAN KOR CHN JPN USA NLD CUW,
  with add-a-country chips and an EU 27 comparison line
- CPI index levels rebased to a chosen base year, index/YoY toggle,
  categories (food, energy, housing, core) where the source has them
- new no-key collectors: oecd (CPI categories, 10y yields), statcan (CPI);
  bis WS_LONG_CPI, FRED US CPI categories, Eurostat prc_hicp_minr, WB FP.CPI.TOTL
- Canada added as a region; CBS Curacao fallbacks for growth/unemployment
- timers stats-oecd and stats-statcan
2026-10-26 00:34:27 +00:00
28140bd425 lists: movies-shows-to-watch moves into /trackers/movies (301 to the watchlist once archived)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 00:31:45 +00:00
71e015d1d0 Add movies & TV tracker: movies schema, Netflix/Letterboxd/Plex/JSON/recs importers, TMDB matching, stats, owner visibility 2026-10-26 00:31:23 +00:00
75571bbfe0 lists: retire scientific-papers (items live in /trackers/books?kind=papers) and cars-trains-planes-to-spot (-> /trackers/spotting); archived list URLs 301, books-to-read too
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-26 00:24:27 +00:00
d71e39f40a stats: world opinions moves to /stats/opinions/world, the energy tracker merges into /stats/energy (live NED section), 301s from /projects/world-opinions and /projects/energy, tracker-style icons for the stats header, nav, sub-tabs and home grid
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
2026-10-26 00:16:08 +00:00
be3473caa0 pubg map: built-in intel layers (vehicle spots, drops, loot density, Blue Chip towers, secret rooms) per map
All checks were successful
Deploy production / deploy (push) Successful in 1m12s
Read-only layers from static/pubg-intel/<map>.v1.json (scripts/build-pubg-intel.py):
positions from pubg.gs (game level files), drops from guides, secret rooms and Deston
keys as approximate rings on the named place. Layer panel (K), per-player choices in
localStorage, drawn below room markers; clicks still place markers.
2026-10-26 00:05:46 +00:00
b1edf7761c Dockerfile: ship src/lib/server/ai-import.ts so the AI tracker import scripts run in the app image
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-26 00:01:36 +00:00
3d3f008037 pc tracker: Megekko new build (9950X3D/X870E), dated ordered/leaving badges, future slider
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 23:59:15 +00:00
cf0fec973c AI tracker: /trackers/ai with public news, owner-only bots and usage; ai schema (news_items, bot_status, usage_entries); idempotent import scripts; preflight ai grants check
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 23:58:39 +00:00
9b21220280 stats: /stats/history and History, Politics and Culture sections on NL, Gouda, Curaçao, US and KR pages (step 4)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
Long-run charts (Maddison, HYDE, UN via OWID; CBS and Pew religion), a filterable
sourced timeline, Wikidata office holders and turnout, Nager.Date holidays, UNESCO
sites, Wikidata people by sitelinks, curated food and cultural exports. Wikidata
labels now fall back to mul.
2026-10-25 23:39:34 +00:00
e0bcd63ce3 deploy preflight: 55 stats tables (step 4 history tables)
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 23:27:46 +00:00
0088e5202e stats: history and culture collector (step 4): events, offices, people, holidays, UNESCO, religion, long-run series
Some checks failed
Deploy production / deploy (push) Failing after 1m0s
2026-10-25 23:25:46 +00:00
fa3aa25f0b house-scrape: re-check huispedia, huislijn, huurportaal and rentola rows and mark clearly gone ones removed
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 23:14:42 +00:00
241b90d2f9 stats elections: distinct colour for the SP family
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 22:48:55 +00:00
0fe0d36f87 PUBG drop map: live squad map with WebSocket rooms
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
- server.js wraps Bun.serve to add /ws/<channel> upgrades (same server and port) and
  serve the map tiles from the host mount; Dockerfile now runs bun ./server.js
- ws hub (channel registry, origin check) + pubg-map channel: rooms by unguessable
  code, validated + rate-limited messages, per-room write queue, presence
- games.pubg_rooms / pubg_matches / pubg_markers; per-match sessions with history
- /trackers/games/pubg/map hub (owner: rooms, landings over time, history) and
  /trackers/games/pubg/map/<code> fullscreen Leaflet map (all 11 maps)
- deploy-remote.sh bind-mounts ~/pubg-maps; scripts/build-pubg-tiles.py rebuilds it
2026-10-25 22:46:30 +00:00
d93374bca2 stats step 3 pages: /stats/elections (NL 1918→, turnout, seats, Gouda per polling station, US by state) and /stats/opinions (NL/US/KR polls, Eurobarometer issues and trust, WHR, Google Trends, GDELT themes, CHES and V-Party); nav, cards, sitemap; scatter in the chart wrapper
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 22:44:09 +00:00
e1fcd4f321 stats elections: CBS 37278 percentages to votes; Kiesraad municipal votes and turnout from EML kieskring counts; ballots include blank/invalid
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-25 22:24:15 +00:00
42c9351ebd stats step 3: elections, party positions and opinion feeds (schema, collectors, timers); world-opinions data moved into stats.wo_*
All checks were successful
Deploy production / deploy (push) Successful in 1m12s
2026-10-25 22:16:25 +00:00
030f3c08ee PC tracker: Intel AX201 Wi-Fi image
All checks were successful
Deploy production / deploy (push) Successful in 1m12s
2026-10-25 21:51:46 +00:00
031ee4baf2 stats §8: Curaçao from ILOSTAT (SDMX), OWID, UN SDG 531, UN Comtrade 531 (cwintl + weekly timer), EU trade with CW (Eurostat ext_lt_maineu); CBS Curaçao LFS file pick fix; Curaçao detail panels on /stats/countries/CUW
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 21:47:06 +00:00
a6b4f9931d pc: drop shared brand from grouped card label
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 21:39:11 +00:00
ff1520ae8b stats §8: CBS Curaçao collector (topic-page scrape, sha256 manifest, CPI since 1970 from PDF, GDP by industry, government finance, population since 1900, LFS, tourism) into cw_cbs_* + stats.obs region CUW; cw_cbcs_bop for manual uploads; weekly timer
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 21:38:30 +00:00
27a15f0428 pc: grouped ×N cards list each item on its own line, shared brand in label
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-25 21:34:33 +00:00
3c5ef605df pc: self-hosted images for Samsung 970 EVO 1TB (Windows 11 drive)
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 21:29:03 +00:00
fa58e5c661 stats B11: EP-Online energy labels (monthly total file → homes per label for NL, Gouda, Gouda PC4s; aggregates only), stats.ep_label_agg, monthly timer with key from ~/.config/stats/ep-online.env, charts on /stats/energy and /stats/gouda
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 21:28:37 +00:00
2a4466cb82 stats: /stats/countries comparison and /stats/countries/[iso3] profiles (Curaçao via World Bank), more WB indicators, Countries + Curaçao cards, nav/sitemap/start links
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 21:22:20 +00:00
d19313035f stats: Eurostat pre-2007 household energy bands (nrg_pc_204_h/202_h folded into nrg_pc_204/202), stitched on /stats/energy; IMF revenue/spending from Fiscal Monitor codes
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 21:14:50 +00:00
02734607cc stats: cast jsonb chunk param via text so jsonb_populate_recordset gets an array (step 2 loads)
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 21:12:12 +00:00
8958d3ff8f stats step 2: World Bank, IMF WEO, OWID, Energy-Charts, Rijksfinanciën, US Treasury, USAspending collectors + tables, timers
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 21:05:51 +00:00
7e905b1651 stats: index neighbour growth from 2016 (after mergers), clearer tariff and levy labels
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 20:56:29 +00:00
76bfb3dcec stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps,
  solar, cars, population (monthly, since 1942, components), house prices, housing stock,
  municipal levies for Gouda and its neighbours
- shared StatChart/Kpi/chart helpers with per-chart source attribution
- drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
2026-10-25 20:50:47 +00:00
6dcbcfee25 groceries stats: fix array/json params for the app client (live store filter 500), CPI axis, header spacing
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 20:48:00 +00:00
8e35a58095 groceries stats: quote day alias in shrinkflation query
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 20:42:32 +00:00
a6ef263c7e groceries: stats page with nightly snapshot (battles, basket, promos, CPI, range)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
2026-10-25 20:39:35 +00:00
c4397e9d59 stats: fix role-name quoting in the stats migration grants; add deploy/stats-collector (systemd user timers for CBS, Eurostat, FRED, BIS)
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
2026-10-25 20:36:00 +00:00
8ef291f586 stats: new public stats schema (source/series/obs/region/fetch_run + raw CBS, Eurostat, FRED, BIS tables) and the stats collector (scripts/stats-fetch.ts); preflight checks the stats tables
Some checks failed
Deploy production / deploy (push) Failing after 54s
2026-10-25 20:27:21 +00:00
902b1cadd5 header: sharper moon logo (112px webp from full-res photo, tight crop); regenerate favicons/app icons with tight moon crop
All checks were successful
Deploy production / deploy (push) Successful in 1m12s
2026-10-25 20:26:05 +00:00
3ad13e5828 games: active vs non-active games (games.library.active, owner toggles on the dashboard and each game ⋯ menu; nothing deleted). Header flyout and mobile menu list every game split into active / non-active; /trackers and home tiles show active chips and open both sections on hover; /start shows active with a non-active toggle. Games dashboard moved from /projects/game-tracker to /trackers/games (dashboard of active games only; old URL and /credits 301); deep trackers stay put
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 20:23:15 +00:00
60370d4572 groceries: relevance-ranked search (plain/basic products first)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- search.ts: score on the core name (brand/size stripped): exact or query+base suffix
  (kipdij+filet), head-final compounds, prefix, inside; penalties per extra word and per
  flavour/processing word (search-words.ts, editable); bonuses for store brands, the
  query's dominant category, store count, favourites; trigram tiebreak
- groceries.synonyms table (seeded: kipdij/kippendij, pasta → spaghetti, …) expands queries
- list rows show all pack sizes of a group; group picker uses the same ranking
- scripts/groceries-search-eval.ts prints top results + latency; preflight now 8 tables
2026-10-25 20:20:34 +00:00
5028524537 chore: bump bun to 1.4.4 (CVE-2026-111497)
All checks were successful
Deploy production / deploy (push) Successful in 1m24s
2026-10-25 20:08:51 +00:00
27abff569c start: drag and drop links to reorder within a section or move them to another section (view and edit mode); orderLinks can also change the dragged link's section
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
2026-10-25 20:03:54 +00:00
1cfbe6486a groceries: Action importer (allowed category listings: price, unit price, EAN from image name; food/drinks, personal care, pets, household consumables) + store row
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 19:40:53 +00:00
0e98acd0a8 groceries: Lidl — search API only public for CDN-cached URLs (401 otherwise); later pages best-effort, off the nightly run
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 19:31:39 +00:00
e2af941b88 deploy preflight: groceries schema now has 7 tables (categories, manual_prices)
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 19:12:23 +00:00
707f07efd4 groceries: mobile-first tracker redesign — compact rows per product group (cheapest store badge, unit price, spread, promo), tap to compare stores; category tree (23 tops, subcategories, icons/colours) with chips, store tiles, sort and non-food toggle; owner shelf prices (manual source with date, photo, note)
Some checks failed
Deploy production / deploy (push) Failing after 1m1s
2026-10-25 19:10:10 +00:00
67d74f39b8 groceries: ALDI importer (category pages SSR Algolia hits, offers; no EANs) + store row
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 19:04:37 +00:00
cce6ec3f2b groceries: Vomar importer (public product gateway, EANs; regular prices only) + store row
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
2026-10-25 18:57:21 +00:00
1bab96c20d games: chain the split migration snapshot after hoogvliet; games flyout tooltip shows the full game name
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 18:57:06 +00:00
e4162d3a78 games: split Call of Duty into one entry per title and Pokémon into one per generation (remakes and spin-offs separate), full names, official logo for each (Steam icons / English logos); data migration moves bo2 status+aliases to black-ops-2; /trackers/games/bo2 and /pokemon redirect; three-column games flyout
All checks were successful
Deploy production / deploy (push) Successful in 1m16s
2026-10-25 18:54:50 +00:00
942c436d03 groceries: Hoogvliet importer (sitemap SKUs + storefront GraphQL, EANs, promo labels) + store row; Lidl retries transient CDN 401s
All checks were successful
Deploy production / deploy (push) Successful in 1m24s
2026-10-25 18:52:01 +00:00
c897ae3d14 start: live server stats over SSE (1 s): CPU, RAM, net, disk IO with 60 s sparklines, per-container CPU/mem/state; one shared netdata poller that runs only while /start is open (owner-only /start/live)
All checks were successful
Deploy production / deploy (push) Successful in 1m29s
2026-10-25 18:49:00 +00:00
ce8b545f60 groceries: Lidl importer (online range), Dirk importer + store row, generic per-store GTIN backfill
All checks were successful
Deploy production / deploy (push) Successful in 1m36s
2026-10-25 18:41:12 +00:00
5d3e8d0261 games: official logos for all 16 game trackers (self-hosted, one map in #lib/game-logos) shown by TrackerIcon everywhere games appear; coloured tile stays as fallback; logo credits on the artwork credits page
All checks were successful
Deploy production / deploy (push) Successful in 1m24s
2026-10-25 18:35:21 +00:00
eba5f9eda3 start: one-container stacks as a single row (project · service), groups only for multi-container stacks; same memory units in rows and headers
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 17:41:10 +00:00
48ec29beac start: group Docker containers by compose project (collapsible stacks with running count, health dot, CPU/mem); collector captures compose labels
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 17:38:23 +00:00
3f30bb883f groceries: add PLUS, Xenos and Amazing Oriental stores, is_food flag, PLUS EAN backfill
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 17:17:53 +00:00
b23f7b3475 docs: prod host is stan-ovh (comments still said stan-vm)
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 17:01:50 +00:00
5a87a079eb pc setup: hero falls back to the case present on the chosen day; add-ons (tilt pivot) are not counted as extra units in a slot card
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-25 16:35:30 +00:00
ec4d3fd79e pc setup: 2017 build (GTX 1070, i7-6700K, B150, HyperX Fury, 212 EVO, 850 EVO + WD Blue, SPEC-01, 650 G1, SickleFlow, AS5) images, Ergotron HX arm images; add-ons sort after their base item in a slot card; SEO says since 2017
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-25 16:32:28 +00:00
676ac2ad64 trackers: lock icon after owner-only tracker names (header dropdown, mobile menu, home, /trackers, /start, error suggestions) from one locked flag in #lib/projects; drop the private: subtitle prefix
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 16:22:25 +00:00
eff5a24985 pc setup v2: one-screen layout with a time slider, photo grid and lightbox
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
- hardware: slots, sections, usage, install/removal dates, images and a hidden flag on
  components and devices; setup_photos table; setup_overview view gains the new columns
- data: hide the 2021 friends-PC build and the wrong 140mm / unknown fans (kept, not deleted);
  7x SL-INF + 3 Kraken stock fans; mice, audio, desk extras; MacBook Pro 16 M2 Pro 1 TB;
  dates for every swap so the slider can rebuild the setup on any day
- self-hosted product images in static/hardware (webp + thumbs)
- prices and order details are owner-only (in a popover)
2026-10-25 16:16:38 +00:00
d414e7190e games ingest: send dates as ISO strings (the app pool uses drizzle's date serializers), pause Leetify on 429 and resume next poll, FACEIT 403 from the server is a skip (history imported by backfill), weekly FACEIT
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 16:15:13 +00:00
0b7509bbbc money: room under the €0 baseline so it sits clear of the chart edge and small dips below zero show
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 16:11:09 +00:00
772e699655 data holders: private tracker of every organisation holding my address, email, phone or IBAN
Some checks failed
Deploy production / deploy (push) Has been cancelled
dataholders schema (orgs + identity) with migration and personal_app grants,
idempotent CSV import (upsert on name+login, never wipes, keeps site edits and
ticks), owner-only /trackers/data-holders with search, filters, pagination,
inline edit/add and a moving checklist per identifier
2026-10-25 16:10:24 +00:00
fd4a34b495 money: thick €0 baseline on the wealth charts (all modes) so dips toward zero or below stand out
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-25 16:06:48 +00:00
80803ef5ac games: automatic CS2 / PUBG / R6 match history — games.matches, match_players, seasons, snapshots, accounts; in-app poller (Leetify, FACEIT, Steam share codes, PUBG API, Ubisoft) every 20 min plus scripts/games-ingest.ts for backfills; match history, stats, maps and seasons on the per-game pages
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-25 16:00:48 +00:00
9114b5433b money: archived accounts (accounts.archived) — closed banks stay in history, net worth and charts but leave the quick balances, quick add and account pickers; archived section in the accounts panel; quick add defaults to Revolut
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 15:42:40 +00:00
968ec34ac5 games: collapse non-featured game chips behind +N more; slide-only flyout animation
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 15:25:41 +00:00
d79b73ad83 games: per-game pages, games.library table, games group in header/tiles/start
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 15:20:45 +00:00
037a525f91 spotters: brands, makers and operators with a logo first, activity order kept inside each group
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-25 15:05:11 +00:00
8045362852 money: left-align accounts list rows (undo Button centring, fixed height and nowrap)
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-25 15:00:49 +00:00
152880f5ab spotters: 120 website-icon logos for makers and operators without a free Commons logo
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 14:51:33 +00:00
7aeb31c46a spotters: stricter logo checks (no photos, plates, insignia or old names; --recheck), planes operator bonus weighted down for historic types, Türkiye for iNat
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 14:44:27 +00:00
3668c9e383 gitignore python bytecode
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 14:24:50 +00:00
4af041719c start: wider spotting tile, filter narrows spotting chips
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 14:24:11 +00:00
27987fb8e7 reading: owner archive / restore / delete / merge for books (⋯ menu on cards, rows and detail pages), archived filter, tombstones so merged ids redirect and the ingest never recreates deleted works, books-quarantine.py for deleted files
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 14:22:40 +00:00
8a8ed1642e start: trackers section mirroring the header dropdown (same order and icons, spotting chips, f filter)
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
2026-10-25 14:21:23 +00:00
11fa8ec006 spotters: fix 500 on detail pages of seen items (timestamp comes back as text); logos script uses small separate Wikidata queries
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 14:10:53 +00:00
0f8db8e432 music: fix tooltip and streak spacing, sort genre tooltip, shorter clock cells
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 14:06:43 +00:00
516bc15545 music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
- Range picker (?range=7d|30d|1y|all) drives every section; the hours chart
  covers the whole range (daily for 7d/30d, weekly for 1y, monthly for all).
- Genres from Last.fm artist top tags (music.artist_tags, normalised and
  filtered), split by artist listening time, with a per-bucket stacked chart.
- Listening clock, new artists, comparison with the previous period and top
  movers, streaks, milestones, top albums with art, rank changes.
- music.play_facts materialized view (refreshed by the app when plays arrive)
  plus a per-range cache keep the page fast.
- scripts/music-fetch-tags.ts backfills tags, most-played artists first.
2026-10-25 14:03:39 +00:00
5e4cd83748 spotters: logo coverage script (Wikidata logo/icon, multi-language infobox, website icon fallback), cheaper planes operator queries, refresh adds logos for new orgs
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 13:54:27 +00:00
7d6cee26a8 house tracker: contact status per listing (whose turn, last message, viewing) and labelled filters
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 13:53:33 +00:00
df081b8058 music: backup play log from now-playing samples (origin nowplaying; 30 s or half the track, ignoring pauses and seeks); recently-played takes those rows over with its exact timestamp
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 13:52:38 +00:00
d6c5dde2c1 reading: hide book-only stats on the papers/articles tabs, site letter on articles without an image
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 13:48:05 +00:00
e31e1be73f reading: split the book tracker into books, papers and articles (kind column + migration classifying 20 papers), segmented tabs with counts, article cards that open the link, owner + add link with server-side unfurl, kind switch on detail pages, /trackers/reading alias
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-25 13:42:51 +00:00
7b5faf6e74 start: collapsible link groups (remembered per browser), links filter (f, Enter opens first match), brand marks + saved favicons for bookmarked sites
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 13:41:59 +00:00
628e5cb9b3 music poller: a 429 on recently-played pauses only the play log for the full Retry-After; now playing keeps polling every 5 s; player 429s honour Retry-After up to 24 h
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-25 13:25:25 +00:00
253cd69424 spotters: most-active-in sort with country/region selector, collection tracking (seen marks, progress, filters), refresh scripts score activity and report new NL entries
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 13:24:37 +00:00
3f294df337 music: hours tooltip opens on tap (focus no longer closes it straight away)
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 13:21:28 +00:00
20ad42b247 handleError for SvelteKit 3: ids only for unknown errors, compact 404 log line; error page suggestions prefer the real parent over the section
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
2026-10-25 13:15:41 +00:00
92d6da959d daisy-hook: accept a full "Name: value" header setting, redact the key from logs, retry once; start: brand icons on link and service tiles (trimmed simple-icons + saved favicons, monogram fallback)
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 13:12:16 +00:00
a89a187346 Global error page: ErrorView with per-status copy, did-you-mean suggestions, error ids from handleError, static error.html; 404 instead of 500 for bad progress ids; tolerate full header line in DAISY_TASK_WEBHOOK_HEADER and stop logging raw fetch errors
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 13:10:51 +00:00
f86c8ef294 spotters: activity (per-country score) and seen (collection) tables for cars, planes, trains, wildlife
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
2026-10-25 13:09:02 +00:00
85c6f89e36 music: styled hover/focus/tap tooltip with exact hours and date on the hours-per-day chart
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 13:08:06 +00:00
cc34f5751b spotters: fix random 404 on client navigation (random is a page load, not an endpoint shadowed by [slug]); square logo tiles everywhere
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-25 13:07:26 +00:00
45943543c2 trackers: one-click spotting links: header flyout (hover/focus, delayed close, side-aware), chips on the spotting tile, mobile sub-links
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-25 12:18:33 +00:00
d8490ce94f trackers: group cars, trains, planes and wildlife under one spotting tracker with /trackers/spotting landing page and category switcher
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 12:08:38 +00:00
5226ed7fec trackers: colourful tracker icons on /trackers and home tiles, 3/4-column desktop grid with lift hover
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 11:27:56 +00:00
704880dc98 trains: stricter Commons photo matching (category files must name the type; no maps, class interiors, trucks)
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 00:13:30 +00:00
5f940e8196 trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
2026-10-25 00:11:50 +00:00
8c0fc74283 trains: Wikidata/Wikipedia import, Commons photos and refresh scripts
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
2026-10-25 00:09:07 +00:00
ef9524aa0f trains: schema and migration
All checks were successful
Deploy production / deploy (push) Successful in 1m5s
2026-10-24 23:22:20 +00:00
8515cf0e9c wildlife: photos script without response cache (it outgrew JSON.stringify)
All checks were successful
Deploy production / deploy (push) Successful in 1m5s
2026-10-24 22:42:26 +00:00
602ea9c8e1 wildlife: fetch English names without the NL place (it localised them to Dutch)
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
2026-10-24 22:27:16 +00:00
42b6b6568a wildlife: pokedex pages (list, groups, group, species detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
2026-10-24 22:04:15 +00:00
ba44365c71 wildlife: iNaturalist/Wikidata/GBIF import, CC photos and refresh script
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-24 21:58:35 +00:00
3f251145be wildlife: schema and migration
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
2026-10-24 21:54:25 +00:00
59b35c0886 planes: pass maker sources to the maker page
All checks were successful
Deploy production / deploy (push) Successful in 1m3s
2026-10-24 20:51:49 +00:00
eccae362ec planes: stricter photo matching, skip company articles, payload-not-pax
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
2026-10-24 20:26:13 +00:00
6696bc7162 planes: pokedex tracker pages, cards, makers, random 2026-10-24 20:10:36 +00:00
2259965a28 planes: wikidata/wikipedia import, commons photos and refresh script 2026-10-24 20:10:36 +00:00
cc6c7c8ede planes: schema and migration 2026-10-24 20:10:36 +00:00
6249e6daeb start: server links -> stan-ovh (Tailscale), drop Webmin from home-collector
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
2026-10-24 18:41:53 +00:00
085a30bec8 pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
- model the existing hardware schema in Drizzle (devices, components, new sensitivities)
- idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables
- seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S),
  other devices, sensitivity placeholders (no invented cm/360)
- owner-only add/edit via modals; public view hides notes/order numbers
- trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
2026-10-24 17:55:48 +00:00
8442d59b68 cars: brand logos everywhere (search cards, brand grid, model headers), stricter logo matching + curated fixes, logo refresh in cars-photos
All checks were successful
Deploy production / deploy (push) Successful in 2m24s
2026-10-24 17:43:47 +00:00
fac49b57ba music: near-realtime now playing (currently-playing every 5 s while playing, 30 s idle backoff, Retry-After, recent plays ~10 s after a track change, holder-based lease) and a public SSE stream /api/listening/stream feeding the listening card live with client-side progress
All checks were successful
Deploy production / deploy (push) Successful in 2m35s
2026-10-24 17:28:33 +00:00
7636f0b2bc cars: keep MAZDA2/JAECOO7 names (no false typo strip), canonical upload.wikimedia.org thumbs, tidier gallery captions/credits
All checks were successful
Deploy production / deploy (push) Successful in 2m34s
2026-10-24 16:54:14 +00:00
bf0a1e1d2a cars: Wikimedia Commons photos (cars.photos, card heroes, model gallery + lightbox, brand hero/logo), cars:photos + cars:refresh with --report-new, fold duplicate names (Golf VI, Peugot, Mustang Mach 1, Model 3/Y, i10/i30, Ceed, Lynk & Co)
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
2026-10-24 16:44:38 +00:00
Daisy
0612270545 chore: redeploy to load DAISY_TASK_WEBHOOK env
All checks were successful
Deploy production / deploy (push) Successful in 52s
2026-10-24 16:30:50 +00:00
901d12f71d Header tiles: Trackers dropdown, dashboard and quick add as hoverable monochrome tiles
All checks were successful
Deploy production / deploy (push) Successful in 2m48s
Lift + tint on hover, visible focus ring, aria-current for the dashboard,
colourful icons on a black-and-white surface, prefers-reduced-motion respected.
2026-10-24 16:17:27 +00:00
771f64c168 drizzle: chain osu_match_cache snapshot after tasks_auto_handle
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 16:16:19 +00:00
46703f1a6c map finder: quick lists from my listening (top/recent/now/Spotify top/liked), Artist - Title without Spotify, owned playlists via the user token, rich beatmap cards (cover, mapper, status, BPM, length, star chips, preview, osu!direct), mode/status/star filters, DB result cache (music.osu_match_cache)
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 16:15:56 +00:00
0aa14cebf3 Quick add task from the header (owner): modal, g t shortcut, auto_handle + Daisy webhook
Some checks failed
Deploy production / deploy (push) Has been cancelled
- + button next to Dashboard (desktop and mobile sheet), lazy-loaded native <dialog>
- POST /trackers/tasks/quick inserts source 'quick-add', notes, due date, priority
- tasks.tasks.auto_handle (default false) with a Daisy badge in the tracker
- DAISY_TASK_WEBHOOK_URL/KEY/HEADER: fire-and-forget POST {task_id, title}, 3 s timeout
2026-10-24 16:13:45 +00:00
3903317a59 spotify: manual 302 for external redirects in /api/spotify/connect (kit refuses redirect() to external origins)
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 16:12:10 +00:00
4738f10ad1 header: trackers menu as a 2-column grid (desktop and mobile) with a coloured inline-SVG tile per tracker
All checks were successful
Deploy production / deploy (push) Successful in 2m41s
TrackerIcon + #lib/tracker-icons: one gradient rounded tile and a few stroke paths per
tracker (tasks, money, books, cars, music, health, groceries, houses, energy, games, skins,
pets, lists), distinct colours, readable in light and dark. No icon library. Test checks
every tracker has a unique tile and tasks/money/books stay first.
2026-10-24 16:06:39 +00:00
bc58e97776 start: show the normal site header on /start (with the owner dashboard button, highlighted there); no footer on the new-tab page
All checks were successful
Deploy production / deploy (push) Successful in 2m36s
/start is no longer immersive, so it loads the lazily imported SiteChrome like other pages.
Only the book reader still skips the chrome. hidesFooter() keeps the footer off /start.
2026-10-24 16:03:06 +00:00
b757269151 music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing,
  spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has
- in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed
- /api/spotify/connect + callback (owner only), /trackers/music public stats
- scripts: music-poll, music-import-lastfm (resumable), music-import-history
- deploy/music-collector: optional host timer with its own role
2026-10-24 15:57:40 +00:00
45c124a365 cars: default to most-common-in-NL order; dex numbers oldest first with unknown years last (--renumber)
All checks were successful
Deploy production / deploy (push) Successful in 2m41s
2026-10-24 15:47:44 +00:00
9e30e4246f cars: import handles models without years, keeps roman numerals upper-case; don't cache an empty overview
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 15:46:25 +00:00
a04e5faebe houses: zero-pad Leefbaarometer grid cell ids (cells west of x=100 km, e.g. Zevenhuizen, Rotterdam)
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 15:45:26 +00:00
113eb54cd9 houses: Leefbaarometer liveability per listing (PDOK geocode + LBM 2024 grid/buurt), min-score filter and sort; store every regional listing (no price caps at scrape time)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
2026-10-24 15:40:32 +00:00
e95b7da1e3 cars: car pokedex tracker (brands, models, US variants from RDW + fueleconomy.gov), import script, search/filter UI, model pages
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 15:39:08 +00:00
cc4b10f239 site: owner-only dashboard link in header, mobile menu and homepage (g then d from any page); /start skips the site chrome JS
All checks were successful
Deploy production / deploy (push) Successful in 2m56s
- Header/footer moved to #lib/components/SiteChrome.svelte, returned by a universal
  +layout.ts load only for non-immersive pages, so /start and the book reader never
  download icon libraries, dropdowns or the bits-ui Sheet. /start: 36 -> 18 JS files,
  133 -> 78 KB gzip.
- Header logo is a hashed asset (inlined), so it is cached with the build.
- inlineStyleThreshold 4096: tiny page/component CSS is inlined into the HTML.
- Umami is not loaded on /start.
2026-10-24 15:35:38 +00:00
Daisy
112c31d194 houses: default filter max €2000, 2 bedrooms (3 rooms), 50 m²
All checks were successful
Deploy production / deploy (push) Successful in 2m46s
2026-10-24 15:30:48 +00:00
2bd668bf5e start: private new-tab dashboard at /start (tasks, notifications, backups/timers, server + containers, service status, trackers, editable links, / search)
All checks were successful
Deploy production / deploy (push) Successful in 2m39s
- home schema: home.links (seeded, owner-editable) and home.snapshots (written by a host collector)
- deploy/home-collector: stdlib Python + systemd user timer (60s) that snapshots /proc stats,
  docker containers, systemd timers, service HTTP checks and ntfy topics via a limited role
- extras/newtab-extension: MV3 new-tab override, zipped to static/newtab-extension.zip
- owner-only pages skip Google Analytics; /trackers/health#body anchor for weigh-ins
2026-10-24 15:21:29 +00:00
61ae72e50b money scenarios: forecast horizon 1–100y (?horizon=), today’s-euro view, scaling gridlines, career paths with job hops and plateaus, crash cycles per portfolio, retirement at 68 with withdrawals, salary path table
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
2026-10-24 15:13:20 +00:00
c4689adda4 groceries: fix search 500 (pg_trgm word_similarity lives in public, app search_path is $user); guard test for unqualified trgm calls
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
2026-10-24 15:07:08 +00:00
807f8af167 groceries: grouping ignores 'durum tarwe' and possessive store lines (Jumbo's), so house pasta lines up across stores
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
2026-10-24 15:03:39 +00:00
8c9a08afa1 houses: set cache-control once (layout already does; the page load threw header_already_set for the owner)
Some checks failed
Deploy production / deploy (push) Has been cancelled
2026-10-24 15:03:36 +00:00
b5748e5ae5 houses: one row per listing when a hand-added link is scraped later
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
The nightly scraper and worker API now take over an existing row (e.g. source=manual)
whose link is the same listing (normalized URL, or the Pararius key / Funda, Kamernet,
Vesteda id in it) instead of inserting a copy; typed values are kept where the scrape has
none. house-listing update/add give manual rows with such links the site key unless
another row has it, update accepts --source/--source-id, and lookups by link ignore
tracking params. scripts/house-rekey.ts backfills existing manual rows.
2026-10-24 14:59:03 +00:00
c827deb395 money scenarios: contract timing for salary extras (13th month in Nov, holiday pay in May, bonus in Aug, net estimates from the contract)
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
2026-10-24 14:46:32 +00:00
8a29d5ad59 money: pessimistic/realistic/optimistic scenarios on the wealth chart (realistic by default), editable assumptions, yearly table and milestones
All checks were successful
Deploy production / deploy (push) Successful in 2m30s
2026-10-24 14:39:26 +00:00
35f78ec829 Money: private Documents page with admin-only file endpoint
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Lists contracts/pension docs with written summaries, an inline viewer and
download at /trackers/money/documents. Files and summaries live on the host
(MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in
git or static/; the file endpoint is admin-only (401/403) and private, no-store.
2026-10-24 14:32:28 +00:00
8818dde99d houses: keep Kamernet to the Gouda region (small-city pages are padded with listings from all over NL), share GOUDA_REGION
All checks were successful
Deploy production / deploy (push) Successful in 2m42s
2026-10-24 14:22:58 +00:00
07d2c70973 health: smart-scale columns, deduped body measurement imports, quick-add form
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
2026-10-24 14:16:10 +00:00
b51fef14fd houses: nightly Kamernet + Vesteda scraper, dedupe by address, owner-only /trackers/houses
All checks were successful
Deploy production / deploy (push) Successful in 2m44s
- scripts/house-scrape.ts: polite nightly scrape (honest UA, 6-10 s apart, stops a source on 403/challenge), ingest + price history + removals, ntfy alerts on a separate topic (max 5 + summary, first run seeds)
- address_key + duplicate_of: copies on another source link to one listing, manual wins
- /projects/house-tracker -> /trackers/houses (308), owner only (404 otherwise); filters for min m2, rooms, source, Gouda region default; notes, add-by-hand form, total rent, also-on links
- scripts/house-db.sh: run the house-listing CLI as the limited house_cli role
- deploy/systemd/house-scrape.{service,timer}
2026-10-24 14:13:29 +00:00
ea7d64a556 Dockerfile: ship house-tracker helpers so the listings CLI runs in the prod image
All checks were successful
Deploy production / deploy (push) Successful in 2m44s
2026-10-24 14:09:13 +00:00
6e528da403 house tracker: CLI to add, update, list and remove listings by hand
scripts/house-listing.ts (bun run house add|update|list|remove|restore) upserts
house.listings on (source, source_id) derived from the listing URL or address,
derives city_slug, records price_history, and accepts flags or JSON.
2026-10-24 14:09:13 +00:00
85da5afdfe money: wishlist tier totals fit on one line (compact €, scales with card, exact amount in tooltip)
All checks were successful
Deploy production / deploy (push) Successful in 2m54s
2026-10-24 14:09:07 +00:00
427f4ed095 groceries: nightly AH/Jumbo catalogue import, unit-price normalisation, product groups and compare UI
All checks were successful
Deploy production / deploy (push) Successful in 2m48s
2026-10-24 14:01:26 +00:00
8dde5aa302 money: Revolut statement importer (CSV/Excel/PDF) with dedupe, dry run and start-balance removal
All checks were successful
Deploy production / deploy (push) Successful in 2m34s
2026-10-24 13:48:01 +00:00
5f67ad36e7 Groceries: private tracker at /trackers/groceries (stores, products, price history per store, favorites, cheapest highlighted); seeded AH, Jumbo, Lidl, Plus
All checks were successful
Deploy production / deploy (push) Successful in 2m40s
2026-10-24 13:25:49 +00:00
b77c3f8cf5 Tasks: import TickTick backup CSV (csv: keys, matched against API rows, rekeyed when the API returns them)
All checks were successful
Deploy production / deploy (push) Successful in 2m29s
2026-10-24 03:28:14 +00:00
1754ea9316 Tasks: ignore placeholder checklist completion dates from TickTick
All checks were successful
Deploy production / deploy (push) Successful in 2m34s
2026-10-24 03:10:53 +00:00
6a127de950 Tasks: TickTick history sync (export + idempotent import, start_at/time_zone); trackers ordered tasks, money, books with single-noun names
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
2026-10-24 03:05:05 +00:00
9d08e6af23 Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
2026-10-24 02:52:18 +00:00
941a9f16e1 data browser wip 2026-10-24 02:52:18 +00:00
c8205e474e Money: wish list sorted by tier then price (desc), theme-aware tier colours
All checks were successful
Deploy production / deploy (push) Successful in 2m31s
2026-10-24 02:43:31 +00:00
81ee02f11a books reader: fix the error when leaving a PDF (back button)
Some checks failed
Deploy production / deploy (push) Has been cancelled
pdf.js 6 removed PDFDocumentProxy.destroy(), so the PdfReader teardown threw
"destroy is not a function" on every unmount, breaking the client-side
navigation back to the book page (and sometimes showing the error page).
Close the document through its loading task instead (closePdf), which also
cancels a load still in flight. Test covers the pdf.js API and closePdf.
2026-10-24 02:40:32 +00:00
eb80a830f5 Task + goal tracker: private tasks schema, /trackers/tasks page, idempotent seed import (daisy-import)
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
2026-10-24 02:37:48 +00:00
988c6c31a9 Money: move /projects/money-tracker to /trackers/money, rename to Money, 308 redirects from old paths (bank-callback query kept)
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
2026-10-24 02:37:07 +00:00
2af279e8a9 Money tracker: vertical month/year gridlines on wealth chart, keep range in ?range= URL param
All checks were successful
Deploy production / deploy (push) Successful in 2m36s
2026-10-24 02:25:13 +00:00
faeb76a7e7 books reader: zoom, 1/2-page spreads, scroll vs paged mode
Some checks failed
Deploy production / deploy (push) Has been cancelled
- Zoom buttons + Ctrl/Cmd +/-/0, Ctrl+wheel/trackpad pinch, touch pinch on PDFs;
  PDF scales the pdf.js render (sharp re-render after it settles), EPUB scales text.
  Remembered per format, and per book.
- One/two pages side by side: PDF rows with the cover alone, foliate max-column-count.
- Scroll vs Pages mode for both engines; PDF paged shows one page/spread fitted to the
  screen, prev/next move a whole spread; spreads stack when scrolling.
- Reading position is kept across zoom/spread/mode changes; progress keeps saving.
- layout.ts helpers with unit tests; old flow/fontSize/pdfZoom settings migrate.
2026-10-24 02:24:35 +00:00
a022c45499 Money tracker: accounts can be left out of total wealth (include_in_total)
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Adds money.accounts.include_in_total (default true). Excluded accounts keep their own balance and line, but are left out of net worth, invested, this month, the projection and the total/All accounts lines.
2026-10-24 02:14:03 +00:00
6f82d1350e deploy: write the books compose override in the deploy account home
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Run #79 failed at "sudo tee /opt/personal-website/compose.books.yml": the deploy
account may only sudo-tee .env. Write the override to ~deploy instead; the
compose CLI runs as root and reads it from there.
2026-10-24 02:04:12 +00:00
7a039b23f4 deploy: check the books dir from a root container (deploy user cannot traverse /home/stan)
Some checks failed
Deploy production / deploy (push) Failing after 2m12s
Run #78 skipped the /books mount because [ -d /home/stan/books/library ] runs as
the deploy account, which has no access to /home/stan (0750). Check inside a
container with the dir bind-mounted instead; the mount itself is done by dockerd.
2026-10-24 01:59:11 +00:00
8445eef3cf books migration: only call meta.sync() when permitted; deploy runs it as the app role
All checks were successful
Deploy production / deploy (push) Successful in 2m35s
Run #77 failed in db-migrate with "permission denied for function sync": in prod
meta.sync() is granted to personal_app/personal_ddl, not to the schema owner
(personal) that runs migrations. The migration still registers the books tables in
meta.tables and syncs when it can (dev); deploy-remote.sh now runs
scripts/db-meta-sync.ts as DB_URL after migrating. Verified on a prod clone with
SET ROLE personal / personal_app.
2026-10-24 01:53:30 +00:00
1d4cfd58c5 books: import zod as a namespace so tests pass under the Bun runtime in CI
Some checks failed
Deploy production / deploy (push) Failing after 2m20s
The Docker build runs vitest via bun (no Node in oven/bun), where the named
`{ z }` import from zod 4 resolved to undefined; the rest of the repo already
uses `import * as z from 'zod'`.
2026-10-24 01:47:10 +00:00
763d7881fb Money tracker: Knab CSV import, multi-bank linking, sum reconcile, history range
Some checks failed
Deploy production / deploy (push) Failing after 23s
- Knab statement exports: per-account ids (knab:<account>:<Referentie>), routed by IBAN,
  transfers between own accounts merged into one tracker account are dropped (interest kept)
- Import: multiple files, hand-entered twins get the import id, hand entries equal to the
  sum of 2-3 payments within a day are replaced by them
- bank-link --bank revolut|abn|knab with per-bank default account; ASPSP notes printed
- bank-sync: longest history on an account first sync (--full to force), sum reconcile,
  per-session status so one bank failing does not mark the others
- Timeline: 1 year / 3 years / All history range
2026-10-24 01:37:40 +00:00
2cada618bf books: regenerate migration as 20261024013208_books after money_bank_balance (old 010449/010518 would sort before the applied 011919 and be skipped)
Some checks failed
Deploy production / deploy (push) Failing after 27s
2026-10-24 01:35:04 +00:00
7914b0140b books-ingest: --no-move rehearsal mode, stable cover filenames, finished status, module-level log 2026-10-24 01:31:57 +00:00
178fa8ddce books-ingest: safer title/ISBN matching, junk-author filter, GB quota breaker 2026-10-24 01:31:57 +00:00
9c8f20a618 books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README 2026-10-24 01:31:57 +00:00
9c46e281c2 books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests 2026-10-24 01:31:57 +00:00
8b2dfed239 books: schema, migration and ingest script (rebased onto main; migration regenerated after money_bank_sync) 2026-10-24 01:31:57 +00:00
2da375451a Money tracker: bank balance is the source of truth for synced accounts
All checks were successful
Deploy production / deploy (push) Successful in 2m43s
Sync stores the bank-reported balance (ITAV preferred) on the account and keeps
one Bank balance adjustment row so the tracker total equals the bank balance.
Accounts panel shows the bank balance and flags any drift.
2026-10-24 01:20:20 +00:00
e4de2dcec3 Add privacy and terms pages
All checks were successful
Deploy production / deploy (push) Successful in 2m28s
2026-10-24 00:50:46 +00:00
00c1e8b1e7 money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements 2026-10-24 00:50:46 +00:00
9a1b21ec39 money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed) 2026-10-24 00:50:45 +00:00
b1ce715ec7 money: bank-sync schema (account scope/iban/external uid, transaction external id/source, bank_sessions) 2026-10-24 00:50:45 +00:00
064d41595f preflight: verify health grants and energy data
All checks were successful
Deploy production / deploy (push) Successful in 2m20s
2026-10-24 00:27:29 +00:00
fa2456b99e Merge branch 'energy-tracker' into energy-merge 2026-10-24 00:25:34 +00:00
0cf5d44062 Add energy tracker (/projects/energy)
Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
2026-10-23 23:11:27 +00:00
1387 changed files with 1702030 additions and 2974 deletions

View File

@@ -39,3 +39,9 @@ HOUSE_WORKER_TOKEN=
HOUSE_DISCORD_WEBHOOK_URL= HOUSE_DISCORD_WEBHOOK_URL=
HOUSE_NTFY_URL= HOUSE_NTFY_URL=
HOUSE_NTFY_TOKEN= HOUSE_NTFY_TOKEN=
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
ENABLEBANKING_APP_ID=
ENABLEBANKING_KEY_PATH=
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ vite.config.ts.timestamp-*
# Browser test output # Browser test output
/test-results/ /test-results/
/playwright-report/ /playwright-report/
__pycache__/

View File

@@ -19,3 +19,12 @@ data/cod-challenges.json
data/game-images.json data/game-images.json
data/cod-achievement-art.json data/cod-achievement-art.json
src/lib/house-tracker/fixtures/ src/lib/house-tracker/fixtures/
# Vendored third-party code
static/vendor/
# Generated drizzle snapshots
drizzle/*/snapshot.json
# Generated PUBG map intel (scripts/build-pubg-intel.py)
static/pubg-intel/

View File

@@ -1,4 +1,4 @@
FROM oven/bun:1.4.0 AS builder FROM oven/bun:1.4.4 AS builder
WORKDIR /app WORKDIR /app
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -6,7 +6,7 @@ COPY . .
RUN bun run test:unit -- --run RUN bun run test:unit -- --run
RUN bun run build RUN bun run build
FROM oven/bun:1.4.0 FROM oven/bun:1.4.4
WORKDIR /app WORKDIR /app
ARG GIT_SHA=dev ARG GIT_SHA=dev
ENV APP_VERSION=$GIT_SHA ENV APP_VERSION=$GIT_SHA
@@ -17,9 +17,20 @@ COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/data ./data COPY --from=builder /app/data ./data
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
# scripts/house-listing.ts (manual listings CLI) imports these pure helpers.
COPY --from=builder /app/src/lib/house-tracker ./src/lib/house-tracker
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
# scripts/music-*.ts (Spotify poll, Last.fm and streaming-history imports) import these.
COPY --from=builder /app/src/lib/music ./src/lib/music
# scripts/games-ingest.ts (match history backfills) imports the games ingest.
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
COPY --from=builder /app/server.js ./server.js
COPY package.json ./ COPY package.json ./
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV BODY_SIZE_LIMIT=6M ENV BODY_SIZE_LIMIT=6M
CMD ["bun", "./build"] # server.js wraps the adapter-bun build: same server, plus /ws/<channel> WebSockets and the
# PUBG map tiles (/pubg-tiles from the PUBG_TILES_ROOT bind mount, see scripts/deploy-remote.sh).
CMD ["bun", "./server.js"]

View File

@@ -236,13 +236,49 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …) old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
upsert reference data and run on every production deploy. upsert reference data and run on every production deploy.
## Money tracker ## Book tracker
`/projects/money-tracker` is restricted to administrator accounts. Its tables `/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
position per file, autosaves progress and sessions, and stores highlights with notes.
Files are never public. They are streamed (with Range support) by
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
`hooks.server.ts`).
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
be opened.
## Money
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
are created by `npm run db:migrate:money-tracker`; apply that migration to each are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns. all nine tracker tables and the projection columns.
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
written summary, an inline viewer and a download. Nothing about them is in git: the
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
`/money-documents` when it exists, otherwise `~/money-documents`) as
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
`/home/stan/money-documents` read-only at `/money-documents` when it has a
`documents.json`. Files are only streamed to admins via
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
no redeploy is needed.
Transactions can be entered manually or imported from a CSV or tab-separated Transactions can be entered manually or imported from a CSV or tab-separated
export with `date,account,description,amount` headers. Dates accept export with `date,account,description,amount` headers. Dates accept
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros `YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
@@ -306,6 +342,28 @@ registered callback URL. The integration follows the installed Better Auth APIs;
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit) see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth). and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
## Notes
`/trackers/notes` (owner-only, always: every page, API, attachment and the `/ws/notes` socket 404s
for anyone else) is a web editor for Stan's Obsidian vault, two-way and near-real-time.
- **Source of truth:** the markdown files in `~/obsidian-vault` on the server (Syncthing folder
`obsidian-vault`, synced to his devices), bind-mounted read-write at `/notes` by
`scripts/deploy-remote.sh` (`NOTES_HOST_DIR`). The site reads and writes `.md` files directly:
atomic writes (hidden `.notes-tmp-*` file in the same folder, fsync, rename), chowned to the
vault owner, frontmatter and everything else kept byte-for-byte. Deleting moves the file to
`<vault>/.trash` like Obsidian does.
- **Index only:** `notes.files` / `notes.links` (full-text search, tags, wikilinks, backlinks) are
rebuilt from the files by `src/lib/server/notes/indexer.ts`: recursive `fs.watch` plus a full
rescan every minute. Dropping the tables loses nothing.
- **Live:** file changes (from Obsidian via Syncthing, or the site) are pushed to open pages over
`wss://stanrunge.dev/ws/notes` (paths and hashes only, never content). Saves carry the hash the
editor loaded; if the file changed in between the save is refused (409) and the page offers
load theirs / overwrite / save a copy.
- **Ignored:** anything under a dot entry (`.obsidian`, `.trash`, `.stfolder`, `.stversions`,
`.DS_Store`, Syncthing temps). `*.sync-conflict-*` files are listed as warnings, not indexed.
- Syncthing should ignore the site's write temps: `(?d).notes-tmp-*` in the vault's `.stignore`.
## Development ## Development
There are two environments: local dev and production. Prod is the source of truth There are two environments: local dev and production. Prod is the source of truth

View File

@@ -14,9 +14,12 @@
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10",
"leaflet": "^1.9.4",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"world-atlas": "^2.0.2", "world-atlas": "^2.0.2",
@@ -37,6 +40,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1", "@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/leaflet": "^1.9.22",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7", "@typescript/native": "npm:typescript@7",
@@ -325,6 +329,30 @@
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="], "@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
@@ -447,6 +475,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
@@ -745,6 +775,8 @@
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -837,6 +869,8 @@
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], "pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],

View File

@@ -0,0 +1,643 @@
{
"source": "daisy-import",
"areas": [
{
"slug": "deadlines",
"name": "Deadlines and dates",
"sortOrder": 10
},
{
"slug": "house",
"name": "House and moving",
"description": "Jan–Feb 2027 with Juhee",
"sortOrder": 20
},
{
"slug": "kibi",
"name": "Kibi",
"sortOrder": 30
},
{
"slug": "admin",
"name": "Phone, subscriptions and admin",
"sortOrder": 40
},
{
"slug": "clients",
"name": "Clients",
"sortOrder": 50
},
{
"slug": "website",
"name": "Website (stanrunge.dev)",
"sortOrder": 60
},
{
"slug": "setup",
"name": "Setup and security",
"description": "Perma evolvable setup, before 2027",
"sortOrder": 70
},
{
"slug": "home",
"name": "Home",
"sortOrder": 80
},
{
"slug": "health",
"name": "Health and self-care",
"sortOrder": 90
},
{
"slug": "reading",
"name": "Reading",
"sortOrder": 100
},
{
"slug": "planning",
"name": "Planning and goals",
"sortOrder": 110
}
],
"goals": [
{
"title": "€10k net/month into own Revolut by 2030",
"area": "planning",
"targetDate": "2030-01-01",
"metric": "Net income per month",
"metricUnit": "EUR",
"metricTarget": 10000
},
{
"title": "Own a really nice Ford Mustang GT"
},
{
"title": "PC overhaul",
"notes": "Top AMD X3D CPU, RTX 5090.",
"metric": "Budget",
"metricUnit": "EUR",
"metricTarget": 10000
},
{
"title": "Organise goals into a plan",
"area": "planning",
"notes": "Social media, Grok Bot, IT company."
},
{
"title": "Perma evolvable setup foundation ready before 2027",
"area": "setup",
"targetDate": "2026-12-31"
},
{
"title": "Get in shape long term",
"area": "health"
}
],
"tasks": [
{
"area": "deadlines",
"title": "Jan Loman printer visit",
"status": "done",
"notes": "Settled.",
"due": "2026-10-24",
"dueTime": "14:00"
},
{
"area": "deadlines",
"title": "Call BackHome about Kibi's chip",
"status": "todo",
"due": "2026-10-26",
"dueTime": "10:00",
"tags": ["kibi", "call"]
},
{
"area": "deadlines",
"title": "Cargill collective health insurance (OHRA, code 114)",
"status": "todo",
"notes": "Needs current insurer + premium to compare.",
"due": "2026-12-31",
"tags": ["insurance"]
},
{
"area": "deadlines",
"title": "Reply to mom about insurance info",
"status": "todo",
"notes": "Waiting since Oct 9.",
"tags": ["insurance"]
},
{
"area": "deadlines",
"title": "Stop zorgtoeslag from Jan 2027",
"status": "todo",
"due": "2027-01-01",
"reminderAt": "2026-12-07T09:00:00+01:00",
"tags": ["admin"]
},
{
"area": "deadlines",
"title": "Supermarket product list",
"status": "todo",
"reminderAt": "2026-10-24T11:50:00+02:00"
},
{
"area": "deadlines",
"title": "Book haircut",
"status": "todo",
"notes": "Around Nov 14.",
"due": "2026-11-14"
},
{
"area": "house",
"title": "House overview",
"status": "in_progress",
"flagged": true,
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
},
{
"area": "house",
"title": "React to house options",
"status": "todo",
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
},
{
"area": "house",
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
"status": "todo"
},
{
"area": "house",
"title": "Mijndak Midden-Holland sign-up",
"status": "todo",
"flagged": true,
"notes": "€23 option."
},
{
"area": "house",
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
"status": "todo"
},
{
"area": "house",
"title": "House tracker/scraper on the site",
"status": "todo",
"tags": ["website"]
},
{
"area": "house",
"title": "Update address everywhere",
"status": "todo",
"notes": "Checklist ready."
},
{
"area": "house",
"title": "UniFi network for the new place",
"status": "todo",
"tags": ["network"]
},
{
"area": "kibi",
"title": "Food ordered",
"status": "done"
},
{
"area": "kibi",
"title": "Chip scanner ordered",
"status": "done"
},
{
"area": "kibi",
"title": "Chip number found",
"status": "done"
},
{
"area": "kibi",
"title": "Chip owner change at BackHome",
"status": "todo",
"notes": "Still 'Kwiebus' under E Zwart."
},
{
"area": "kibi",
"title": "Call vet about Kibi's patient card",
"status": "todo",
"tags": ["call"]
},
{
"area": "kibi",
"title": "GPS tracker",
"status": "todo",
"notes": "Dad may have done it already."
},
{
"area": "kibi",
"title": "Dash KIBI",
"status": "todo",
"flagged": true,
"notes": "Meaning unclear, ask Stan."
},
{
"area": "admin",
"title": "Basic-Fit cancelled",
"status": "done"
},
{
"area": "admin",
"title": "Ohra inboedelverzekering cancelled",
"status": "done"
},
{
"area": "admin",
"title": "Zorgtoeslag income updated for 2026",
"status": "done"
},
{
"area": "admin",
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
"status": "in_progress"
},
{
"area": "admin",
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
"status": "todo"
},
{
"area": "admin",
"title": "Check Oasen and Delta Energie fully cancelled",
"status": "todo"
},
{
"area": "admin",
"title": "Cancel other unused subscriptions",
"status": "todo"
},
{
"area": "admin",
"title": "Merge banks into Revolut",
"status": "todo"
},
{
"area": "clients",
"title": "Quote dack €450 for vet clinic site fix",
"status": "done",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Reply to dack",
"status": "done",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Cloudflare DNS/SSL for dack",
"status": "in_progress",
"tags": ["dack"],
"notes": "Grind-week goal."
},
{
"area": "clients",
"title": "Send dack's invoice via Moneybird",
"status": "todo",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
"status": "todo",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
"status": "todo",
"tags": ["karolina"]
},
{
"area": "clients",
"title": "Wix Studio + Velo/CLI so an AI can edit code",
"status": "todo",
"tags": ["karolina"]
},
{
"area": "website",
"title": "DB reorganised into schemas, staging retired",
"status": "done"
},
{
"area": "website",
"title": "Energy tracker (NED data) live",
"status": "done"
},
{
"area": "website",
"title": "Health tracker (private) + 15 RIVM vaccinations",
"status": "done"
},
{
"area": "website",
"title": "Book tracker, reader, 362 books live",
"status": "done"
},
{
"area": "website",
"title": "Reader zoom, 2-page view, scroll/paging",
"status": "in_progress"
},
{
"area": "website",
"title": "Money tracker with MoneyMan",
"status": "in_progress"
},
{
"area": "website",
"title": "NED backfill",
"status": "in_progress"
},
{
"area": "website",
"title": "Music listening tracker + now-playing",
"status": "todo"
},
{
"area": "website",
"title": "Task/goal tracker to replace TickTick",
"status": "in_progress"
},
{
"area": "website",
"title": "Hardware DB (current PC parts first)",
"status": "todo"
},
{
"area": "website",
"title": "List of every organisation holding my data and what they know",
"status": "todo"
},
{
"area": "website",
"title": "Move to SvelteKit 3",
"status": "todo"
},
{
"area": "website",
"title": "Juhee website data",
"status": "todo"
},
{
"area": "setup",
"title": "AWS cleanup ($0/month)",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Google Cloud cleanup",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Nightly restic backups to B2 + Storage Box",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "WhatsApp bridge on stan-vm",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "gcloud + moneybird-cli on stan-vm",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Domains Squarespace to Cloudflare",
"status": "in_progress",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Both transfers pending.",
"due": "2026-10-29"
},
{
"area": "setup",
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
"status": "in_progress",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Backup follow-up: restic passwords into 1Password",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"due": "2026-10-24",
"dueTime": "11:00"
},
{
"area": "setup",
"title": "Delete empty B2 bucket",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"due": "2026-10-25"
},
{
"area": "setup",
"title": "Rotate weak admin secret + DB password",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["security"]
},
{
"area": "setup",
"title": "1Password overhaul",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
"tags": ["security"]
},
{
"area": "setup",
"title": "Moneybird API token, then clean up Moneybird",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Authentik SSO on stan-vm",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "vash-prod memory warnings + finish security audit",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["security"]
},
{
"area": "setup",
"title": "Remove old grok-bot devices from Tailscale",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "AWS part optional."
},
{
"area": "setup",
"title": "Raise WSL RAM",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Sees 8 of 16 GB."
},
{
"area": "setup",
"title": "Home server rebuild with ZFS",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Analytics, monitoring, alerts across devices",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Mouse sensitivity cm/360 across devices",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Buy Logitech G840 XL mousepad",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["buy"]
},
{
"area": "setup",
"title": "Router and networking setup",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["network"]
},
{
"area": "setup",
"title": "J5 Hexagon research, then move wachtboden",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Agent-deck + AI/Linux dev setup",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "MITM/certificate lab",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Idea.",
"tags": ["idea"]
},
{
"area": "home",
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
"status": "todo"
},
{
"area": "home",
"title": "Clean desk, laundry, clothes",
"status": "todo"
},
{
"area": "home",
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
"status": "todo"
},
{
"area": "home",
"title": "Hue lights + Home Assistant + Apple Home",
"status": "todo"
},
{
"area": "home",
"title": "Fan controller",
"status": "todo"
},
{
"area": "home",
"title": "Opa's stuff (WhatsApp, sensor install)",
"status": "todo"
},
{
"area": "health",
"title": "Getting in shape with Doc",
"status": "in_progress",
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
"goal": "Get in shape long term"
},
{
"area": "health",
"title": "Join the gym my parents use",
"status": "todo",
"goal": "Get in shape long term"
},
{
"area": "health",
"title": "Brush teeth twice a day, shower, hair care",
"status": "todo",
"tags": ["habit"]
},
{
"area": "health",
"title": "Dentist checkup",
"status": "todo"
},
{
"area": "health",
"title": "Pick hardware for Withings/Garmin syncs",
"status": "todo"
},
{
"area": "health",
"title": "Curaçao vaccines",
"status": "todo",
"notes": "Via Doc."
},
{
"area": "reading",
"title": "Read 'De meeste mensen deugen' (Bregman)",
"status": "todo"
},
{
"area": "reading",
"title": "Re-download 'Haves and Have-Nots'",
"status": "todo"
},
{
"area": "reading",
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
"status": "todo"
},
{
"area": "planning",
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
"status": "todo",
"goal": "Organise goals into a plan"
}
]
}

View File

@@ -0,0 +1,7 @@
[
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
]

1274
data/worldtodo-seed.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
# home-collector
Feeds the private dashboard at `/start` (also the browser new-tab page). The app container has
no Docker socket and must not get one, so a small stdlib-only Python script runs on the host
every minute (systemd **user** timer `home-collector.timer`) and upserts two rows into
`home.snapshots`:
| key | contents |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host` | CPU %, load, uptime, RAM, swap, disk; every Docker container (state, health, restart count, CPU/mem via `sudo docker`); systemd timers (`stan-backup*`, `postgres-backup*`, `disk-guard-*`, `house-scrape`, `b2-cost`, `groceries-import`, `money-sync`, `ned-collector-*`) with last/next run and result; HTTP up/down + latency for each self-hosted service; latest `~/backups-cost/daily/*.json` |
| `ntfy` | the last 40 messages (72 h) from the ntfy topics in `~/.config/disk-guard.env` (`NTFY_URL`) and `~/.config/house-scrape.env` (`HOUSE_NTFY_URL`), plus `NTFY_EXTRA`. Only a label is stored, never the topic name. |
The page reads both rows in the same parallel query batch as tasks and links, so a slow
upstream never delays it; a snapshot older than 3 minutes is shown as stale.
## Live stats (1 s)
On top of the minute snapshot, `/start` opens a Server-Sent Events stream (`/start/live`,
owner only) while the tab is visible. The app reads netdata on the host
(`NETDATA_URL`, default `http://100.116.93.104:19999`, the Tailscale-only bind) once a second
through one shared poller (`src/lib/server/live-stats.ts`): host CPU, RAM, network, disk IO
and per-container CPU/mem/state/health (netdata's docker and cgroups collectors), so the app
still never touches the Docker socket. The poller stops 5 s after the last viewer leaves.
The app container reaches netdata through one ufw rule:
`ufw allow in on br-c1585f0009ac from 172.26.0.0/16 to 100.116.93.104 port 19999 proto tcp`
(the `backend` compose network's bridge).
## Setup
1. A login role that may only upsert snapshots (created once, cluster superuser):
`CREATE ROLE home_collector LOGIN CONNECTION LIMIT 3 PASSWORD '…'; GRANT CONNECT ON DATABASE personal TO home_collector;`
The `home` migration grants `USAGE` on the schema and `SELECT, INSERT, UPDATE` on
`home.snapshots` when the role exists (re-run those two GRANTs by hand if the role is created later).
2. `~/.config/home-collector.env` (mode 600):
```
HOME_DB_URL=postgresql://home_collector:…@127.0.0.1:5440/personal
# optional extra topics, e.g. the backup alerts from /etc/stan-backup:
# NTFY_EXTRA=backups=https://ntfy.sh/<topic>
```
3. `bash deploy/home-collector/install.sh` (copies the script to `~/.local/bin/home-collector`
and enables the timer). Re-run after editing the script, e.g. to add a service to `SERVICES`.
Debug: `python3 ~/.local/bin/home-collector --print`, `journalctl --user -u home-collector -n 20`.

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""Host-side collector for the /start dashboard on stanrunge.dev.
Runs every minute as a systemd user timer on the server (home-collector.timer) and upserts
two rows into home.snapshots with the limited `home_collector` role:
host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts,
CPU/mem, compose project/service), systemd timer results (backups, disk-guard, scrapers) and service checks
ntfy the most recent messages from the owner's ntfy topics
The app container has no Docker socket and never gets one; this script uses the host's
passwordless `sudo docker`. ntfy topic names stay on the host: only a label is stored.
Config: ~/.config/home-collector.env (mode 600)
HOME_DB_URL=postgres://home_collector:...@127.0.0.1:5440/<db>
NTFY_EXTRA=label=https://ntfy.sh/topic,... (optional extra topics, e.g. backups)
Topics are also read from ~/.config/disk-guard.env (NTFY_URL) and
~/.config/house-scrape.env (HOUSE_NTFY_URL).
Stdlib only. `--print` prints the snapshot instead of writing it; HOME_DB_URL in the
environment overrides the env file (e.g. to fill the dev database).
"""
import concurrent.futures as cf
import json
import os
import secrets
import shutil
import socket
import ssl
import subprocess
import sys
import time
import urllib.request
HOME = os.path.expanduser('~')
CFG = os.path.join(HOME, '.config')
TS = 'stan-ovh.tail665403.ts.net'
# (name, link shown on the dashboard, url checked from the host or None = link only)
SERVICES = [
('Website', 'https://stanrunge.dev', 'http://127.0.0.1:3000/login'),
('Gitea', 'https://git.stanrunge.dev', 'http://127.0.0.1:3010/'),
('Immich', 'https://photos.stanrunge.dev', 'http://127.0.0.1:2283/'),
('Umami', 'https://analytics.stanrunge.dev', 'http://127.0.0.1:3110/'),
('pgAdmin', 'https://pgadmin.stanrunge.dev', 'http://127.0.0.1:5050/misc/ping'),
('Vash esports', 'https://esports.vash.software', 'http://127.0.0.1:3100/'),
('Vash staging', 'https://esports-staging.vash.software', 'http://127.0.0.1:3101/'),
('Dozzle logs', 'https://logs.vash.software', 'http://127.0.0.1:3111/'),
('Beeriokart', 'https://beeriokart.stanrunge.dev', 'http://127.0.0.1:3003/'),
('Stuhee', 'https://stuhee.stanrunge.dev', 'http://127.0.0.1:3004/'),
('Dev site', 'https://dev.stanrunge.dev', 'http://127.0.0.1:3002/'),
('Netdata', f'http://{TS}:19999', 'http://127.0.0.1:19999/api/v1/info'),
('Portainer', f'https://{TS}:9443', 'https://127.0.0.1:9443/'),
('ntfy', 'https://ntfy.sh/app', 'https://ntfy.sh/v1/health'),
]
# (label, unit, scope)
TIMERS = [
('Backup (restic → B2)', 'stan-backup', 'system'),
('Backup maintenance', 'stan-backup-maintain', 'system'),
('Postgres dump', 'postgres-backup', 'system'),
('Postgres dump prune', 'postgres-backup-prune', 'system'),
('Disk guard check', 'disk-guard-check', 'user'),
('Disk guard cleanup', 'disk-guard-cleanup', 'user'),
('House scrape', 'house-scrape', 'user'),
('B2 cost', 'b2-cost', 'user'),
('Groceries import', 'groceries-import', 'user'),
('Money sync', 'money-sync', 'user'),
('Energy (NED) hourly', 'ned-collector-hourly', 'user'),
('Energy (NED) daily', 'ned-collector-daily', 'user'),
]
def read_env(path):
out = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
out[k.strip()] = v.strip().strip('"').strip("'")
except OSError:
pass
return out
def run(cmd, timeout=15):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout if r.returncode == 0 else ''
except Exception:
return ''
def cpu_times():
with open('/proc/stat') as f:
parts = [int(x) for x in f.readline().split()[1:]]
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
return idle, sum(parts[:8])
def meminfo():
m = {}
with open('/proc/meminfo') as f:
for line in f:
k, v = line.split(':', 1)
m[k] = int(v.split()[0]) * 1024
return m
def system_stats():
i1, t1 = cpu_times()
time.sleep(1)
i2, t2 = cpu_times()
cpu = 100.0 * (1 - (i2 - i1) / max(1, t2 - t1))
m = meminfo()
with open('/proc/uptime') as f:
uptime = float(f.read().split()[0])
load = list(os.getloadavg())
disks = []
for mount in ('/',):
u = shutil.disk_usage(mount)
disks.append({'mount': mount, 'total': u.total, 'used': u.used, 'free': u.free,
'pct': round(100 * u.used / u.total, 1)})
return {
'hostname': socket.gethostname(),
'cpus': os.cpu_count(),
'cpuPct': round(cpu, 1),
'load': [round(x, 2) for x in load],
'uptimeS': int(uptime),
'mem': {'total': m['MemTotal'], 'available': m['MemAvailable'],
'used': m['MemTotal'] - m['MemAvailable']},
'swap': {'total': m.get('SwapTotal', 0), 'used': m.get('SwapTotal', 0) - m.get('SwapFree', 0)},
'disks': disks,
}
def containers():
ids = run(['sudo', '-n', 'docker', 'ps', '-aq']).split()
if not ids:
return []
try:
info = json.loads(run(['sudo', '-n', 'docker', 'inspect', *ids]) or '[]')
except ValueError:
info = []
stats = {}
for line in run(['sudo', '-n', 'docker', 'stats', '--no-stream', '--format', '{{json .}}'], 20).splitlines():
try:
s = json.loads(line)
stats[s['Name']] = {'cpu': s.get('CPUPerc'), 'mem': s.get('MemUsage', '').split(' / ')[0]}
except (ValueError, KeyError):
pass
out = []
for c in info:
st = c.get('State', {})
name = c.get('Name', '').lstrip('/')
labels = c.get('Config', {}).get('Labels') or {}
out.append({
'name': name,
'image': c.get('Config', {}).get('Image', '').split('@')[0][:80],
'state': st.get('Status'),
'health': (st.get('Health') or {}).get('Status'),
'restarts': c.get('RestartCount', 0),
'exitCode': st.get('ExitCode'),
'startedAt': st.get('StartedAt'),
'cpu': stats.get(name, {}).get('cpu'),
'mem': stats.get(name, {}).get('mem'),
# Compose project/service, so /start can group containers by stack.
'project': labels.get('com.docker.compose.project'),
'service': labels.get('com.docker.compose.service'),
})
out.sort(key=lambda c: c['name'])
return out
def unit_props(unit, scope, props):
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + [
'show', unit, '-p', ','.join(props)]
d = {}
for line in run(cmd).splitlines():
if '=' in line:
k, v = line.split('=', 1)
d[k] = v
return d
def list_timers(scope):
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + ['list-timers', '--all', '-o', 'json']
try:
return {t['unit']: t for t in json.loads(run(cmd) or '[]')}
except (ValueError, KeyError, TypeError):
return {}
def sec(usec):
try:
n = int(usec or 0)
return n // 1_000_000 if n > 0 else None
except (ValueError, TypeError):
return None
def timers():
lists = {'user': list_timers('user'), 'system': list_timers('system')}
out = []
for label, unit, scope in TIMERS + [('Home collector', 'home-collector', 'user')]:
t = lists[scope].get(unit + '.timer')
if not t:
continue
s = unit_props(unit + '.service', scope, ['ActiveState', 'Result', 'ExecMainStatus'])
out.append({
'label': label, 'unit': unit, 'scope': scope,
'last': sec(t.get('last')),
'next': sec(t.get('next')),
'running': s.get('ActiveState') in ('activating', 'active', 'deactivating'),
'result': s.get('Result'),
'exitStatus': int(s.get('ExecMainStatus') or 0),
})
return out
_insecure = ssl.create_default_context()
_insecure.check_hostname = False
_insecure.verify_mode = ssl.CERT_NONE
def check(service):
name, link, url = service
res = {'name': name, 'url': link, 'ok': None, 'code': None, 'ms': None}
if not url:
return res
start = time.monotonic()
try:
req = urllib.request.Request(url, headers={'User-Agent': 'home-collector'})
with urllib.request.urlopen(req, timeout=5, context=_insecure) as r:
r.read(256)
res['code'] = r.status
except urllib.error.HTTPError as e:
res['code'] = e.code
except Exception:
res['code'] = 0
res['ms'] = round((time.monotonic() - start) * 1000)
res['ok'] = bool(res['code']) and res['code'] < 500
return res
def services():
with cf.ThreadPoolExecutor(8) as ex:
return list(ex.map(check, SERVICES))
def b2_cost():
d = os.path.join(HOME, 'backups-cost', 'daily')
try:
files = sorted(f for f in os.listdir(d) if f.endswith('.json'))
if files:
with open(os.path.join(d, files[-1])) as f:
return json.load(f)
except (OSError, ValueError):
pass
return None
def ntfy_topics(cfg):
topics = []
for label, path, key in (('disk-guard', 'disk-guard.env', 'NTFY_URL'),
('houses', 'house-scrape.env', 'HOUSE_NTFY_URL')):
url = read_env(os.path.join(CFG, path)).get(key)
if url:
topics.append((label, url))
for item in (cfg.get('NTFY_EXTRA') or '').split(','):
if '=' in item:
label, url = item.split('=', 1)
topics.append((label.strip(), url.strip()))
return topics
def ntfy(cfg):
msgs, seen, errors = [], set(), []
for label, url in ntfy_topics(cfg):
if url in seen:
continue
seen.add(url)
try:
req = urllib.request.Request(url.rstrip('/') + '/json?poll=1&since=72h',
headers={'User-Agent': 'home-collector'})
with urllib.request.urlopen(req, timeout=8) as r:
for line in r.read().decode('utf-8', 'replace').splitlines():
try:
m = json.loads(line)
except ValueError:
continue
if m.get('event') != 'message':
continue
msgs.append({'id': m.get('id'), 'topic': label, 'time': m.get('time'),
'title': (m.get('title') or '')[:200],
'message': (m.get('message') or '')[:600],
'priority': m.get('priority', 3), 'tags': m.get('tags') or [],
'click': m.get('click')})
except Exception:
errors.append(label)
msgs.sort(key=lambda m: m['time'] or 0, reverse=True)
return {'messages': msgs[:40], 'topics': [t for t, _ in ntfy_topics(cfg)], 'errors': errors}
def write(db_url, rows):
tag = 'j' + secrets.token_hex(8)
sql = ['BEGIN;']
for key, data in rows.items():
payload = json.dumps(data, separators=(',', ':'))
sql.append(
f"INSERT INTO home.snapshots (key, data, collected_at) VALUES ('{key}', ${tag}${payload}${tag}$::jsonb, now()) "
"ON CONFLICT (key) DO UPDATE SET data = excluded.data, collected_at = excluded.collected_at;")
sql.append('COMMIT;')
r = subprocess.run(['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', db_url], input='\n'.join(sql),
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
# psql errors never contain the password; the URL is not echoed.
sys.stderr.write(r.stderr[-500:])
sys.exit(1)
def main():
cfg = read_env(os.path.join(CFG, 'home-collector.env'))
with cf.ThreadPoolExecutor(4) as ex:
f_sys = ex.submit(system_stats)
f_ct = ex.submit(containers)
f_svc = ex.submit(services)
f_ntfy = ex.submit(ntfy, cfg)
host = f_sys.result()
host['containers'] = f_ct.result()
host['services'] = f_svc.result()
host['timers'] = timers()
host['b2'] = b2_cost()
notes = f_ntfy.result()
rows = {'host': host, 'ntfy': notes}
if '--print' in sys.argv:
print(json.dumps(rows, indent=1))
return
url = os.environ.get('HOME_DB_URL') or cfg.get('HOME_DB_URL')
if not url:
sys.exit('HOME_DB_URL missing in ~/.config/home-collector.env')
write(url, rows)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Snapshot server stats, containers, timers and ntfy for stanrunge.dev/start
Wants=network-online.target
After=network-online.target
ConditionPathExists=%h/.config/home-collector.env
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 %h/.local/bin/home-collector
TimeoutStartSec=50
Nice=10

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run home-collector every minute (stanrunge.dev/start)
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Install/refresh the /start collector as a systemd user timer (run as stan on the server).
# Needs ~/.config/home-collector.env with HOME_DB_URL for the home_collector role (see README).
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
install -D -m 755 "$here/home-collector.py" "$HOME/.local/bin/home-collector"
install -D -m 644 "$here/home-collector.service" "$HOME/.config/systemd/user/home-collector.service"
install -D -m 644 "$here/home-collector.timer" "$HOME/.config/systemd/user/home-collector.timer"
systemctl --user daemon-reload
systemctl --user enable --now home-collector.timer
systemctl --user start home-collector.service || true
systemctl --user --no-pager status home-collector.service | head -5

View File

@@ -0,0 +1,66 @@
# music (self-hosted Last.fm)
`/trackers/music` (stats) and the "listening now" card on the homepage read the `music`
schema in the `personal` database:
| table / view | contents |
| ------------------------- | ----------------------------------------------------------------------------------------------- |
| `artists`, `albums` | Spotify ids when known (imports are matched by name), album art URLs (640 px + small) |
| `tracks`, `track_artists` | `match_key` = normalised `artist\|title` (`src/lib/music/normalize.ts`) for cross-source dedupe |
| `plays` | one row per play: `source` spotify / lastfm / youtube / local, `origin` api / export / import |
| `plays_deduped` (view) | `plays` minus Last.fm scrobbles that Spotify also has (what every stat reads) |
| `now_playing` | row 1, refreshed every minute |
| `spotify_auth` | row 1, refresh token + poller state. **Not** granted to `personal_ro` |
| `import_state` | resumable backfill progress (`lastfm:<user>`) |
## Spotify (live)
1. In the Spotify developer dashboard (the existing app, `SPOTIFY_CLIENT_ID`) add the redirect
URIs `https://stanrunge.dev/api/spotify/callback` and
`https://dev.stanrunge.dev/api/spotify/callback`.
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes:
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private`.
3. That's it: the **app itself** polls every minute (`src/lib/server/music/poller.ts`, started
from `hooks.server.ts` `init`) with its own role and Spotify credentials. Status (last poll,
last error) is shown to the owner on `/trackers/music`.
### Optional: poll from the host instead
Only if the poller should keep running independent of the app container. Needs its own role:
```sh
sudo docker exec -i postgres-db-1 psql -U postgres -d personal <<'SQL'
CREATE ROLE music_collector LOGIN CONNECTION LIMIT 3 PASSWORD '<pick one>';
GRANT CONNECT ON DATABASE personal TO music_collector;
GRANT USAGE ON SCHEMA music TO music_collector;
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
music.plays, music.now_playing, music.import_state TO music_collector;
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
SQL
```
`~/.config/music-collector.env` (mode 600): `MUSIC_DB_URL=postgresql://music_collector:…@127.0.0.1:5440/personal`,
`SPOTIFY_CLIENT_ID=…`, `SPOTIFY_CLIENT_SECRET=…`. Then `bash deploy/music-collector/install.sh --enable`
and set `MUSIC_POLLER=off` in prod.env. (The music migration grants the same when the role
already exists.) Debug: `journalctl --user -u music-collector -n 20`.
## Backfills
Both run from the deployed image with the app role, so no extra credentials:
```sh
tag=$(sudo sed -n 's/^IMAGE_TAG=//p' /opt/personal-website/.env)
# Last.fm (needs an API key from https://www.last.fm/api/account/create); resumable, re-run any time
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
-e LASTFM_API_KEY=<key> git.stanrunge.dev/stan/website:$tag bun scripts/music-import-lastfm.ts --user stanrunge
# Spotify Extended Streaming History (unzipped export in ~/spotify-export)
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
-v ~/spotify-export:/data:ro git.stanrunge.dev/stan/website:$tag bun scripts/music-import-history.ts /data
```
Dedupe: within one source a play within 60 s of the same `match_key` is skipped on insert;
across sources `plays_deduped` hides a Last.fm scrobble when a Spotify play of the same
`match_key` ended between 3 min before and track length + 3 min after the scrobble time
(Last.fm records when a track started, Spotify when it ended). Listening time uses
`ms_played` when known, else the track length.

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Install the optional host-side Spotify poller as a systemd user timer (run as stan on the
# server). Copies the units; enables the timer only with --enable (needs
# ~/.config/music-collector.env, see README.md). Normally not needed: the app polls itself.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
install -D -m 644 "$here/music-collector.service" "$HOME/.config/systemd/user/music-collector.service"
install -D -m 644 "$here/music-collector.timer" "$HOME/.config/systemd/user/music-collector.timer"
systemctl --user daemon-reload
if [ "${1:-}" = --enable ]; then
test -f "$HOME/.config/music-collector.env" || { echo 'Missing ~/.config/music-collector.env' >&2; exit 1; }
systemctl --user enable --now music-collector.timer
systemctl --user start music-collector.service || true
systemctl --user --no-pager status music-collector.service | head -5
else
echo 'Installed (not enabled). Enable with: bash deploy/music-collector/install.sh --enable'
fi

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Spotify scrobbler poll (now playing + recently played -> music.*) for stanrunge.dev/trackers/music
Wants=network-online.target
After=network-online.target
ConditionPathExists=%h/.config/music-collector.env
[Service]
Type=oneshot
# A checkout of stan/website on main. Not the live dev tree.
WorkingDirectory=%h/dev/website-music
# MUSIC_DB_URL (music_collector role), SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET; mode 600.
EnvironmentFile=%h/.config/music-collector.env
ExecStart=/usr/bin/bun scripts/music-poll.ts
TimeoutStartSec=50
Nice=10

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Spotify scrobbler poll every minute (optional; the app polls by itself)
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,49 @@
# stats collector
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 |
Each timer starts `stats-collector@<source>.service`, which runs
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
in `~/dev/stats-runner`, reads `DB_URL` (app role) from the running app container, and runs the
fetch. Every job is logged in `stats.fetch_run` (shown as "data freshness" on /stats).
Politeness: one shared User-Agent, a per-host gap between requests (CBS 250 ms ≈ 4 req/s,
OECD 3.1 s ≈ 19/min, FRED/BIS 1 s), retries with backoff on 429/5xx.
```sh
bash deploy/stats-collector/install.sh # install units + enable timers
systemctl --user start stats-collector@fred.service # run one source now
# long backfills: detached, survive SSH drops; logs in journalctl --user -u stats-backfill-cbs
systemd-run --user --unit=stats-backfill-cbs ~/.local/bin/stats-collector-run.sh cbs --backfill
```
Never touches the `energy` schema or ned-collector.

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Install the /stats collector on the host as systemd user units (run as stan on stan-ovh):
# the runner script, the stats-collector@.service template and one timer per source.
# bash deploy/stats-collector/install.sh install + enable every timer
# bash deploy/stats-collector/install.sh --no-enable install only
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
units="$HOME/.config/systemd/user"
install -D -m 755 "$here/stats-collector-run.sh" "$HOME/.local/bin/stats-collector-run.sh"
install -D -m 644 "$here/stats-collector@.service" "$units/stats-collector@.service"
timers=()
for timer in "$here"/stats-*.timer; do
install -m 644 "$timer" "$units/$(basename "$timer")"
timers+=("$(basename "$timer")")
done
systemctl --user daemon-reload
if [ "${1:-}" != --no-enable ]; then
systemctl --user enable --now "${timers[@]}"
fi
systemctl --user list-timers --no-pager 'stats-*'

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: BIS policy rates daily
[Timer]
OnCalendar=*-*-* 07:50:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@bis.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: CBS StatLine nightly (re-pulls tables whose Modified changed)
[Timer]
OnCalendar=*-*-* 05:40:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@cbs.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: CBS Curaçao files (re-imports a table only when its file hash changes)
[Timer]
OnCalendar=Tue *-*-* 05:50:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@cbscw.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# /stats collector runner (systemd: stats-collector@<source>.service, one timer per source).
# stats-collector-run.sh <source> [--backfill|--force|--only=…]
# Runs scripts/stats-fetch.ts from a detached checkout of origin/main (~/dev/stats-runner), with
# DB_URL read fresh from the app container each run (never stored on disk). One lock per source,
# so a slow backfill and its timer never overlap; a shared lock guards the checkout update.
set -euo pipefail
source_name=${1:?source required (cbs, eurostat, fred, bis, …)}
case "$source_name" in *[!a-z0-9-]*) echo "bad source name" >&2; exit 2 ;; esac
runner="${STATS_RUNNER_DIR:-$HOME/dev/stats-runner}"
locks="${XDG_RUNTIME_DIR:-/tmp}/stats-collector"
mkdir -p "$locks"
exec 9>"$locks/$source_name.lock"
if ! flock -w "${STATS_LOCK_WAIT:-3600}" 9; then
echo "another $source_name run still holds the lock; giving up" >&2
exit 75
fi
if [ ! -e "$runner/.git" ]; then
git -C "$HOME/dev/personal-website" fetch -q origin
git -C "$HOME/dev/personal-website" worktree add -q --detach "$runner" origin/main
fi
(
flock -w 600 8
cd "$runner"
git fetch -q origin
git checkout -q --detach origin/main
bun install --frozen-lockfile --silent
) 8>"$locks/checkout.lock"
cd "$runner"
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
[ -n "$DB_URL" ] || { echo "no DB_URL from app container" >&2; exit 1; }
export DB_URL
# Per-source API keys live in ~/.config/stats/<name>.env (mode 600); exported, never echoed.
case "$source_name" in
eponline) env_file="$HOME/.config/stats/ep-online.env" ;;
*) env_file="" ;;
esac
if [ -n "$env_file" ]; then
[ -r "$env_file" ] || { echo "missing $env_file" >&2; exit 1; }
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
fi
exec nice -n 10 bun scripts/stats-fetch.ts "$@"

View File

@@ -0,0 +1,10 @@
[Unit]
Description=stanrunge.dev /stats collector: %i (open data → stats.*)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=%h/.local/bin/stats-collector-run.sh %i
TimeoutStartSec=3h
Nice=10

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Curaçao from ILOSTAT, OWID, UN SDG and UN Comtrade
[Timer]
OnCalendar=Wed *-*-* 05:50:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@cwintl.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Kiesraad TK results, CBS 37278 history, MIT US presidential (new elections only)
[Timer]
OnCalendar=*-*-15 04:50:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@elections.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Energy-Charts hourly at :19 (off ned-collector's :07/:37)
[Timer]
OnCalendar=*-*-* *:19:00 Europe/Amsterdam
RandomizedDelaySec=2min
Persistent=true
Unit=stats-collector@energycharts.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: EP-Online energy labels, monthly total file (published on the 1st)
[Timer]
OnCalendar=*-*-03 05:20:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@eponline.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Standard Eurobarometer volume A, new waves (monthly check)
[Timer]
OnCalendar=*-*-20 05:35:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@eurobarometer.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Eurostat weekly
[Timer]
OnCalendar=Mon *-*-* 06:20:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@eurostat.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: FRED graph CSV daily
[Timer]
OnCalendar=*-*-* 07:20:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@fred.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: GDELT theme coverage, slow loop at 1 request per 5.5 s (nightly)
[Timer]
OnCalendar=*-*-* 02:30:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@gdelt.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Google Trends trending-now RSS for NL, US, KR (daily)
[Timer]
OnCalendar=*-*-* 21:45:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@gtrends.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: history and culture (Wikidata, UNESCO, Nager.Date, OWID, CBS religion; monthly)
[Timer]
OnCalendar=*-*-03 05:40:00 Europe/Amsterdam
RandomizedDelaySec=30min
Persistent=true
Unit=stats-collector@history.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: IMF WEO monthly (new rounds in April and October)
[Timer]
OnCalendar=*-*-01 04:05:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@imf.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: OECD SDMX CPI categories and 10-year yields, Thursdays
[Timer]
OnCalendar=Thu *-*-* 06:35:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@oecd.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Our World in Data monthly (Maddison, energy dataset)
[Timer]
OnCalendar=*-*-02 04:35:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@owid.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: CHES + V-Party party positions via Party Facts (quarterly)
[Timer]
OnCalendar=*-01,04,07,10-16 04:40:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@parties.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Rijksfinanciën monthly (budget tables)
[Timer]
OnCalendar=*-*-03 05:05:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@rijksfinancien.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Statistics Canada CPI daily
[Timer]
OnCalendar=*-*-* 16:10:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@statcan.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: USAspending weekly (Sunday)
[Timer]
OnCalendar=Sun *-*-* 06:10:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@usaspending.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: US Treasury Fiscal Data daily (after the US afternoon release)
[Timer]
OnCalendar=*-*-* 23:25:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@ustreasury.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: World Happiness Report Figure 2.1 (monthly check)
[Timer]
OnCalendar=*-*-21 05:10:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@whr.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Wikipedia opinion-poll tables (daily)
[Timer]
OnCalendar=*-*-* 08:10:00 Europe/Amsterdam
RandomizedDelaySec=20min
Persistent=true
Unit=stats-collector@wikipolls.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: World Bank WDI weekly (Monday)
[Timer]
OnCalendar=Mon *-*-* 04:15:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@worldbank.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Goal metrics refresh (Moneybird side income -> tasks.metric_sources, auto goals -> tasks.goals)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# A checkout of stan/website on main (needs node_modules for postgres.js).
WorkingDirectory=%h/dev/website-goalbar
# DB_URL (app role), MONEYBIRD_API_TOKEN, MONEYBIRD_ADMINISTRATION_ID; mode 600, never committed.
EnvironmentFile=%h/.config/goal-metrics.env
ExecStart=/usr/bin/bun scripts/goal-metrics.ts
TimeoutStartSec=300

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Goal metrics refresh, daily (salary part is live on page load anyway)
[Timer]
OnCalendar=*-*-* 06:40:00 Europe/Amsterdam
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=House tracker nightly scrape (Kamernet, Vesteda, re-check of imported rows -> house.listings)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# A checkout of stan/website on main. Not the live dev tree.
WorkingDirectory=%h/dev/website-houses
# DB_URL (house_scraper role: house schema only), optional HOUSE_NTFY_URL; mode 600, never committed.
EnvironmentFile=%h/.config/house-scrape.env
ExecStart=/usr/bin/bun scripts/house-scrape.ts
TimeoutStartSec=900

View File

@@ -0,0 +1,10 @@
[Unit]
Description=House tracker scrape, once a night
[Timer]
OnCalendar=*-*-* 03:40:00 Europe/Amsterdam
RandomizedDelaySec=1200
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Money tracker bank sync (Enable Banking -> money.transactions)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# A checkout of stan/website (main once merged). Not the live dev tree.
WorkingDirectory=%h/dev/website-money
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
EnvironmentFile=%h/.config/money-sync.env
ExecStart=/usr/bin/bun scripts/bank-sync.ts
TimeoutStartSec=600

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
[Timer]
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -1,7 +1,72 @@
# House tracker # House tracker
## Nightly scrape on stan-ovh (prod; was stan-vm until 2026-10-24)
`scripts/house-scrape.ts` runs once a night from a systemd user timer
(`deploy/systemd/house-scrape.{service,timer}`, 03:40 Amsterdam + up to 20 min jitter) and
writes `house.listings` directly with the limited `house_scraper` role
(`~/.config/house-scrape.env`, mode 600). One request at a time, 6–10 s apart, honest
User-Agent, robots.txt-allowed paths only. A 403/429/challenge stops that source for the
night and is logged as `challenged`; nothing is retried or worked around.
| source | how | status |
| ---------------------- | ------------------------------------------------------------- | ------------------------------------ |
| Kamernet | `/huren/huurwoningen-<city>` pages, `__NEXT_DATA__` JSON | scraped (10 cities around Gouda) |
| Vesteda | `POST /api/units/search/facet` (what their site calls), 20 km | scraped; rented units count as gone |
| Pararius | Cloudflare 403 from the Hetzner IP | not scraped here; home worker below |
| huurwoningen.nl | same Cloudflare 403 (Pararius group) | skipped |
| Funda | bot-check page ("Je bent bijna op de pagina…") | skipped |
| Huurstunt | Cloudflare challenge | skipped |
| Mijndak Midden-Holland | OutSystems app behind a login, no public listing feed | skipped; use their own e-mail alerts |
**Re-check of imported rows** (`src/lib/house-tracker/recheck.ts`). Rows added by the CLI or
one-off backfills from huispedia, huislijn, huurportaal and rentola are not in any search
feed, so after the sources the scrape fetches each row's own page (all four allow detail
pages in robots.txt), up to 400 per site, oldest `last_seen_at` first, within
`HOUSE_RECHECK_MINUTES` (default 8). Each site has its own one-at-a-time 6–10 s queue.
Alive sets `last_seen_at`; gone sets `removed_at` and needs a clear signal:
| site | gone when |
| -------------------- | ------------------------------------------------------------------------------- |
| huurportaal, rentola | redirect to a search page with `?inactive_property_id=`, or 404/410 |
| huislijn | redirect to the province overview (id gone from the URL), 404/410, woningruil |
| huispedia | 404/410 ("niet meer beschikbaar"), or Status verhuurd / verkocht / niet te koop |
Errors, timeouts, off-site redirects and unfamiliar pages are `unknown` and change nothing; a
403/429/503 or challenge stops that site for the night. If more than half of a site's checks
in one night (min. 5) say gone, none are applied and the run is logged as `suspicious`.
Try it with `--dry-run --source recheck --recheck-limit 5`.
Same home on two sources: `address_key` (street + number + city, normalised, see
`src/lib/house-tracker/address.ts`) links the copy to the original via `duplicate_of`; a
manual entry wins. The page shows one row with "also on …" links. Kamernet never shows house
numbers, so its listings are never merged. Alerts: new non-copy listings matching an alert
rule go to `HOUSE_NTFY_URL` (its own topic, max 5 per night plus one summary); the first run
of a source only seeds.
No caps at scrape time: every listing in the region is stored (Kamernet city pages, Vesteda
with an open rent range, Pararius without a price band). The €2,000 / 2-bedroom / 50 m²
limits are only the page's default filters (clear a field to drop it) and the alert rule.
Liveability: every listing gets a Leefbaarometer 3.0 score (BZK, measurement 2024, published
July 2026; `src/lib/house-tracker/liveability.ts`). The address is geocoded with PDOK
Locatieserver (exact address, else 6-char postcode, else street centroid → marked approx.),
then the 100 m grid cell and the buurt containing the point are read from the official WFS
(`geo.leefbaarometer.nl/lbm3`, layers `clippedgridscore24` / `buurtscore24`). The cell score
is used when there is one (and the location is not street-level), else the buurt. Stored on
the row: `lbm_class` (1-9, zeer onvoldoende … uitstekend), `lbm_onv_class` (overlast &
onveiligheid, 1-9 where 5 = Dutch average), buurt, level, precision, year, and all
sub-scores in `lbm_detail`. Scored by the nightly scrape (new and changed addresses), the
CLI and the page's add form; backfill or re-score with `scripts/house-liveability.ts
[--force]`. For a new measurement, bump `LBM_YEAR` and run it with `--force`.
Manual listings: `scripts/house-listing.ts` (CLI) or the form on the page. Against prod use
`scripts/house-db.sh <command> …`, which runs the CLI as `house_cli` (SELECT/INSERT/UPDATE/
DELETE on house.listings, SELECT/INSERT on house.price_history, nothing else) from
`~/.config/house-cli.env`.
Personal tool: collects Dutch housing listings into Postgres, shows them on Personal tool: collects Dutch housing listings into Postgres, shows them on
`/projects/house-tracker` (admin only) and sends an alert when a new listing `/trackers/houses` (owner only, 404 for everyone else; the old URL redirects) and sends an alert when a new listing
matches a rule. Only Pararius for now; other sources plug in the same way. matches a rule. Only Pararius for now; other sources plug in the same way.
## How it fits together ## How it fits together
@@ -176,3 +241,29 @@ fixture still covers pagination and price on request. Rental `priceEur` is the
base/listed rent used by the search filter; a separately shown total can be base/listed rent used by the search filter; a separately shown total can be
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
reports `parse-error` in the worker log on the page. reports `parse-error` in the worker log on the page.
## Adding listings by hand
`scripts/house-listing.ts` writes `house.listings` directly (links from chats,
screenshots). No restart or deploy is needed for new rows.
```sh
# DATABASE_URL or DB_URL must be set; locally: bun --env-file=.env run house …
bun run house add --url https://vbtverhuurmakelaars.nl/woning/gouda-vest-7-d \
--city Gouda --price "€ 1.422,- p.m." --area 84 --rooms 3 --bedrooms 2 --energy-label A
echo '{"url":"…","city":"Gouda","price_eur":1350,"area_m2":81}' | bun run house add --json -
bun run house add --street "Vest 7-D" --city Gouda --price 1422 # no link (screenshot)
bun run house update <id|url> --status viewing --notes "za 14:00" # "" clears a field
bun run house list [--all] [--city gouda] [--offer buy] [--json]
bun run house remove <id|url> [--hard] # soft remove marks it gone; restore undoes it
```
- `source` is the site (`funda`, `pararius`, `vbt`, `vesteda`, … or `manual`
without a link); `source_id` comes from the URL (Funda's number, Pararius'
worker key, otherwise the path), or a hash of the address. Adding the same
link again updates that row and clears `removed_at`.
- Fields follow `listingDetailSchema` (camelCase, snake_case or the flag names)
and are lenient: "€ 1.422,- p.m." and "84 m²" are parsed. Only given fields are
written. `city_slug` is derived from `city` in Pararius' slug style.
- `--first-seen` sets when it was shared; price changes go to `price_history`.
- Manual rows use `first_seen_lane = 'manual'` and never trigger alerts.

View File

@@ -4,7 +4,6 @@ import { defineConfig } from 'drizzle-kit';
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl) // - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
// - legacy: pre-reorg tables kept read-only for rollback // - legacy: pre-reorg tables kept read-only for rollback
// - energy: owned by ned-collector // - energy: owned by ned-collector
// - hardware: not modelled yet
// Migrations run as the schema owner (`personal`), never as the app role. // Migrations run as the schema owner (`personal`), never as the app role.
export default defineConfig({ export default defineConfig({
dialect: 'postgresql', dialect: 'postgresql',
@@ -20,7 +19,23 @@ export default defineConfig({
'animaldex', 'animaldex',
'site', 'site',
'meta', 'meta',
'health' 'health',
'books',
'tasks',
'groceries',
'home',
'cars',
'music',
'hardware',
'planes',
'wildlife',
'trains',
'dataholders',
'stats',
'ai',
'movies',
'worldtodo',
'notes'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,65 @@
CREATE TABLE "money"."bank_sessions" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"provider" text DEFAULT 'enablebanking' NOT NULL,
"session_id" text NOT NULL CONSTRAINT "bank_sessions_session_id_key" UNIQUE,
"aspsp_name" text NOT NULL,
"aspsp_country" text NOT NULL,
"psu_type" text,
"status" text DEFAULT 'authorized' NOT NULL,
"valid_until" timestamp with time zone,
"accounts" jsonb DEFAULT '[]' NOT NULL,
"last_synced_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "bank_sessions_status_check" CHECK (status IN ('authorized','expired','revoked','error'))
);
--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "scope" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "owner" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "iban" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "external_uid" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "provider" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "external_id" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "booking_status" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "counterparty" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "category" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "raw" jsonb;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "value_date" date;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_external_uid_key" UNIQUE("external_uid");--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_external_id_key" UNIQUE("account_id","external_id");--> statement-breakpoint
CREATE INDEX "bank_sessions_user_id_index" ON "money"."bank_sessions" ("user_id");--> statement-breakpoint
ALTER TABLE "money"."bank_sessions" ADD CONSTRAINT "bank_sessions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_scope_check" CHECK (scope IN ('personal','joint'));--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_provider_check" CHECK (provider IN ('manual','enablebanking'));--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_source_check" CHECK (source IN ('manual','csv','enablebanking'));--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- Backfill: the shared account with Juhee is the only joint one; everything else stays personal.
UPDATE "money"."accounts" SET "scope" = 'joint', "owner" = 'Stan & Juhee' WHERE lower("name") = 'joint';
--> statement-breakpoint
UPDATE "money"."accounts" SET "owner" = 'Stan' WHERE "scope" = 'personal' AND "owner" IS NULL;
--> statement-breakpoint
-- updated_at trigger, same as every other core table (07_triggers_meta_grants.sql).
DO $$
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON money.bank_sessions
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
END IF;
END $$;
--> statement-breakpoint
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh. Default
-- privileges on `money` already cover new tables; this is explicit for clarity.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON money.bank_sessions TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE money.bank_sessions_id_seq TO personal_app;
END IF;
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON money.bank_sessions TO personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
CREATE SCHEMA "books";
--> statement-breakpoint
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> statement-breakpoint
CREATE TABLE "books"."files" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"path" text NOT NULL,
"format" text NOT NULL,
"size_bytes" bigint NOT NULL,
"sha256" text NOT NULL,
"md5" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."notes" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"highlight" text,
"note" text,
"color" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."progress" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"percent" real DEFAULT 0 NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL,
"rating" smallint,
"last_read_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100),
CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5)
);
--> statement-breakpoint
CREATE TABLE "books"."sessions" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"ended_at" timestamp with time zone,
"start_percent" real,
"end_percent" real,
"start_page" integer,
"end_page" integer,
"pages_read" integer,
"source" text DEFAULT 'reader' NOT NULL,
"note" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)),
CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at")
);
--> statement-breakpoint
CREATE TABLE "books"."shelf_works" (
"shelf_id" integer,
"work_id" integer,
"sort_order" integer DEFAULT 0 NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
);
--> statement-breakpoint
CREATE TABLE "books"."shelves" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text 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
);
--> statement-breakpoint
CREATE TABLE "books"."works" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"subtitle" text,
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
"isbn10" text,
"isbn13" text,
"publisher" text,
"year" integer,
"language" text,
"page_count" integer,
"description" text,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"cover_url" text,
"cover_path" text,
"openlibrary_work_id" text,
"openlibrary_edition_id" text,
"google_books_id" text,
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
"source_refs" jsonb DEFAULT '{}' NOT NULL,
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
"metadata_source" text,
"metadata_fetched_at" timestamp with time zone,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
CREATE INDEX "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint
CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint
CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> statement-breakpoint
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA books TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA books TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
INSERT INTO books.shelves (slug, name, sort_order) VALUES
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('books','works','books-works','Books','core',true,false,1000),
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
('books','files','books-files','Book files','core',false,false,1000)
ON CONFLICT DO NOTHING;
-- meta.sync() is executable by personal_app/personal_ddl, not by the migration role
-- (personal) in prod; deploy-remote.sh runs it as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;
--> statement-breakpoint
-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too).
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON books.sessions TO personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
CREATE SCHEMA "tasks";
--> statement-breakpoint
CREATE TABLE "tasks"."activity" (
"id" serial PRIMARY KEY,
"task_id" integer,
"goal_id" integer,
"kind" text NOT NULL,
"from_status" text,
"to_status" text,
"note" text,
"value" numeric(14,2),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
);
--> statement-breakpoint
CREATE TABLE "tasks"."areas" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE TABLE "tasks"."goals" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"notes" text,
"status" text DEFAULT 'active' NOT NULL,
"area_id" integer,
"target_date" date,
"metric" text,
"metric_unit" text,
"metric_target" numeric(14,2),
"metric_current" numeric(14,2),
"flagged" boolean DEFAULT false NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"source" text,
"achieved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "goals_title_check" CHECK (length(btrim("title")) > 0),
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
);
--> statement-breakpoint
CREATE TABLE "tasks"."tasks" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"notes" text,
"status" text DEFAULT 'todo' NOT NULL,
"priority" smallint DEFAULT 0 NOT NULL,
"flagged" boolean DEFAULT false NOT NULL,
"due_date" date,
"due_time" time,
"reminder_at" timestamp with time zone,
"recurrence" text,
"area_id" integer,
"goal_id" integer,
"parent_id" integer,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"source" text,
"external_id" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tasks_title_check" CHECK (length(btrim("title")) > 0),
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
);
--> statement-breakpoint
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Privacy: like health, the task tracker is readable/writable by the app role only
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
REVOKE ALL ON SCHEMA tasks 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 tasks TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
--> statement-breakpoint
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
ON CONFLICT DO NOTHING;
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
CREATE TABLE "meta"."edit_log" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"at" timestamp with time zone DEFAULT now() NOT NULL,
"user_id" text,
"user_email" text,
"schema_name" text NOT NULL,
"table_name" text NOT NULL,
"action" text NOT NULL,
"pk" jsonb,
"before" jsonb,
"after" jsonb,
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
);
--> statement-breakpoint
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- The app role appends to and reads the log; nobody updates or deletes through the app.
-- personal_ro gets no access: before/after values can hold private (health) data.
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
REVOKE ALL ON meta.edit_log FROM personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
CREATE SCHEMA "groceries";
--> statement-breakpoint
CREATE TABLE "groceries"."prices" (
"id" serial PRIMARY KEY,
"product_id" integer NOT NULL,
"store_id" integer NOT NULL,
"price_cents" integer NOT NULL,
"unit_price_cents" integer,
"promo" boolean DEFAULT false NOT NULL,
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
"source" text DEFAULT 'manual' NOT NULL,
"store_product_id" text,
"url" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
);
--> statement-breakpoint
CREATE TABLE "groceries"."products" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"brand" text,
"category" text,
"size" text,
"unit_amount" numeric(10,3),
"unit" text,
"favorite" boolean DEFAULT false NOT NULL,
"notes" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "products_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
);
--> statement-breakpoint
CREATE TABLE "groceries"."stores" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"website" text,
"color" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
-- where migrations run as `personal`.
REVOKE ALL ON SCHEMA groceries 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 groceries TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
--> statement-breakpoint
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
ON CONFLICT DO NOTHING;
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
ALTER TABLE "groceries"."prices" DROP CONSTRAINT "prices_product_id_products_id_fkey";--> statement-breakpoint
DROP TABLE "groceries"."prices";--> statement-breakpoint
DROP TABLE "groceries"."products";--> statement-breakpoint
-- The hand-entered products/prices tables were never used (0 rows); replaced by store items
-- and groups in the next migration. Unregister them from the /lists database browser.
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
DELETE FROM meta.tables WHERE schema_name = 'groceries' AND table_name IN ('products','prices');
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
-- Trigram search for group/item names (trusted extension; the database owner may create it).
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
--> statement-breakpoint
CREATE TABLE "groceries"."groups" (
"id" serial PRIMARY KEY,
"key" text,
"name" text NOT NULL,
"brand" text,
"category" text,
"unit" text,
"family_key" text,
"favorite" boolean DEFAULT false NOT NULL,
"manual" boolean DEFAULT false NOT NULL,
"merged_into_id" integer,
"notes" text,
"item_count" integer DEFAULT 0 NOT NULL,
"store_count" integer DEFAULT 0 NOT NULL,
"store_ids" integer[] DEFAULT '{}'::integer[] NOT NULL,
"min_unit_price_cents" integer,
"min_price_cents" integer,
"promo_count" integer DEFAULT 0 NOT NULL,
"image_url" text,
"search_text" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "groups_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "groups_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
CONSTRAINT "groups_not_self_merged" CHECK ("merged_into_id" IS NULL OR "merged_into_id" <> "id")
);
--> statement-breakpoint
CREATE TABLE "groceries"."import_runs" (
"id" serial PRIMARY KEY,
"store_id" integer NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"status" text DEFAULT 'running' NOT NULL,
"requests" integer DEFAULT 0 NOT NULL,
"fetched" integer DEFAULT 0 NOT NULL,
"inserted" integer DEFAULT 0 NOT NULL,
"price_changes" integer DEFAULT 0 NOT NULL,
"error" text
);
--> statement-breakpoint
CREATE TABLE "groceries"."item_prices" (
"id" serial PRIMARY KEY,
"item_id" integer NOT NULL,
"observed_on" date NOT NULL,
"price_cents" integer,
"promo_price_cents" integer,
"promo_label" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "groceries"."items" (
"id" serial PRIMARY KEY,
"store_id" integer NOT NULL,
"store_product_id" text NOT NULL,
"name" text NOT NULL,
"brand" text,
"size_text" text,
"unit" text,
"unit_amount" numeric(12,4),
"unit_source" text,
"price_cents" integer,
"promo_price_cents" integer,
"promo_label" text,
"unit_price_cents" integer,
"promo_unit_price_cents" integer,
"category" text,
"category_path" text,
"gtin" text,
"gtin_checked_at" timestamp with time zone,
"image_url" text,
"url" text,
"available" boolean DEFAULT true NOT NULL,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"group_id" integer,
"match_key" text,
"match_method" text,
"match_confidence" real,
"match_locked" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "items_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
CONSTRAINT "items_price_check" CHECK ("price_cents" IS NULL OR "price_cents" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "groups_key_key" ON "groceries"."groups" ("key");--> statement-breakpoint
CREATE INDEX "groups_family_idx" ON "groceries"."groups" ("family_key");--> statement-breakpoint
CREATE INDEX "groups_category_idx" ON "groceries"."groups" ("category");--> statement-breakpoint
CREATE INDEX "groups_store_ids_idx" ON "groceries"."groups" USING gin ("store_ids");--> statement-breakpoint
CREATE INDEX "groups_search_trgm_idx" ON "groceries"."groups" USING gin ("search_text" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "groups_list_idx" ON "groceries"."groups" ("store_count","name");--> statement-breakpoint
CREATE INDEX "import_runs_store_idx" ON "groceries"."import_runs" ("store_id","started_at");--> statement-breakpoint
CREATE UNIQUE INDEX "item_prices_item_day_key" ON "groceries"."item_prices" ("item_id","observed_on");--> statement-breakpoint
CREATE UNIQUE INDEX "items_store_product_key" ON "groceries"."items" ("store_id","store_product_id");--> statement-breakpoint
CREATE INDEX "items_group_idx" ON "groceries"."items" ("group_id");--> statement-breakpoint
CREATE INDEX "items_gtin_idx" ON "groceries"."items" ("gtin");--> statement-breakpoint
CREATE INDEX "items_name_trgm_idx" ON "groceries"."items" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
ALTER TABLE "groceries"."groups" ADD CONSTRAINT "groups_merged_into_id_groups_id_fkey" FOREIGN KEY ("merged_into_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "groceries"."import_runs" ADD CONSTRAINT "import_runs_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "groceries"."item_prices" ADD CONSTRAINT "item_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "groceries"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groceries"."groups"("id") ON DELETE SET NULL;--> statement-breakpoint
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['groups','items'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Private: app role only (default privileges from the first groceries migration also apply).
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('groceries','groups','groceries-groups','Grocery groups','core',true,false,1100),
('groceries','items','groceries-items','Grocery store items','core',true,false,1100),
('groceries','item_prices','groceries-item-prices','Grocery price history','core',false,false,1100),
('groceries','import_runs','groceries-import-runs','Grocery import runs','core',false,false,1100)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
ALTER TABLE "house"."listings" ADD COLUMN "address_key" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "duplicate_of" integer;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "total_price_eur" integer;--> statement-breakpoint
CREATE INDEX "listings_address_idx" ON "house"."listings" ("address_key") WHERE address_key IS NOT NULL;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD CONSTRAINT "listings_duplicate_of_listings_id_fkey" FOREIGN KEY ("duplicate_of") REFERENCES "house"."listings"("id") ON DELETE SET NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
ALTER TABLE "health"."body_measurements" ADD COLUMN "muscle_mass_kg" numeric(5,2);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "skeletal_muscle_kg" numeric(5,2);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "bone_mass_kg" numeric(4,2);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "visceral_fat" numeric(4,1);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "body_water_pct" numeric(4,1);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "protein_pct" numeric(4,1);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmr_kcal" integer;--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmi" numeric(4,1);--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "measured_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD COLUMN "source" text;--> statement-breakpoint
CREATE UNIQUE INDEX "body_measurements_date_weight_key" ON "health"."body_measurements" ("date","weight_kg");--> statement-breakpoint
CREATE UNIQUE INDEX "body_measurements_measured_at_key" ON "health"."body_measurements" ("measured_at");--> statement-breakpoint
ALTER TABLE "health"."body_measurements" ADD CONSTRAINT "body_measurements_scale_check" CHECK (("muscle_mass_kg" IS NULL OR "muscle_mass_kg" BETWEEN 5 AND 250)
AND ("skeletal_muscle_kg" IS NULL OR "skeletal_muscle_kg" BETWEEN 5 AND 200)
AND ("bone_mass_kg" IS NULL OR "bone_mass_kg" BETWEEN 0.5 AND 20)
AND ("visceral_fat" IS NULL OR "visceral_fat" BETWEEN 1 AND 60)
AND ("body_water_pct" IS NULL OR "body_water_pct" BETWEEN 20 AND 85)
AND ("protein_pct" IS NULL OR "protein_pct" BETWEEN 5 AND 40)
AND ("bmr_kcal" IS NULL OR "bmr_kcal" BETWEEN 500 AND 6000)
AND ("bmi" IS NULL OR "bmi" BETWEEN 10 AND 80));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
CREATE SCHEMA "home";
--> statement-breakpoint
CREATE TABLE "home"."links" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"url" text NOT NULL,
"section" text DEFAULT 'links' 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 "links_title_check" CHECK (length(btrim("title")) between 1 and 80),
CONSTRAINT "links_url_check" CHECK ("url" ~ '^https?://' and length("url") <= 2000)
);
--> statement-breakpoint
CREATE TABLE "home"."snapshots" (
"key" text PRIMARY KEY,
"data" jsonb NOT NULL,
"collected_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "links_sort_idx" ON "home"."links" ("section","sort_order");--> statement-breakpoint
-- Privacy: like tasks and health, the start page is the app role only (no personal_ro).
-- Grants only run on prod, where migrations run as `personal`.
REVOKE ALL ON SCHEMA home 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 home TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA home TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA home TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
-- The host-side collector (deploy/home-collector) may only upsert snapshots. The role is
-- created out of band (it has a password); see deploy/home-collector/README.md.
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'home_collector') THEN
GRANT USAGE ON SCHEMA home TO home_collector;
GRANT SELECT, INSERT, UPDATE ON home.snapshots TO home_collector;
END IF;
END $$;
--> statement-breakpoint
-- Starter links; edited inline on /start.
INSERT INTO "home"."links" ("title", "url", "section", "sort_order") VALUES
('Gmail', 'https://mail.google.com/mail/u/0/', 'daily', 10),
('Calendar', 'https://calendar.google.com/', 'daily', 20),
('WhatsApp', 'https://web.whatsapp.com/', 'daily', 30),
('Discord', 'https://discord.com/app', 'daily', 40),
('X', 'https://x.com/home', 'daily', 50),
('YouTube', 'https://www.youtube.com/', 'daily', 60),
('Spotify', 'https://open.spotify.com/', 'daily', 70),
('Drive', 'https://drive.google.com/', 'daily', 80),
('Moneybird', 'https://moneybird.com/390525278507501515', 'money', 110),
('Revolut', 'https://app.revolut.com/', 'money', 120),
('Knab', 'https://www.knab.nl/', 'money', 130),
('Tikkie', 'https://www.tikkie.me/', 'money', 140),
('Gitea', 'https://git.stanrunge.dev/', 'dev', 210),
('GitHub', 'https://github.com/', 'dev', 220),
('Cloudflare', 'https://dash.cloudflare.com/', 'dev', 230),
('Tailscale', 'https://login.tailscale.com/admin/machines', 'dev', 240),
('Backblaze B2', 'https://secure.backblaze.com/b2_buckets.htm', 'dev', 250),
('1Password', 'https://my.1password.com/', 'dev', 260),
('osu!', 'https://osu.ppy.sh/', 'fun', 310),
('Twitch', 'https://www.twitch.tv/', 'fun', 320);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
--> statement-breakpoint
CREATE SCHEMA "cars";
--> statement-breakpoint
CREATE TABLE "cars"."brands" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"country" text,
"model_count" integer DEFAULT 0 NOT NULL,
"nl_count" integer DEFAULT 0 NOT NULL,
"first_year" smallint,
"last_year" smallint,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cars"."models" (
"id" serial PRIMARY KEY,
"dex" integer NOT NULL,
"brand_id" integer NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"search" text NOT NULL,
"first_year" smallint,
"last_year" smallint,
"bodies" text[] DEFAULT '{}'::text[] NOT NULL,
"size_classes" text[] DEFAULT '{}'::text[] NOT NULL,
"fuels" text[] DEFAULT '{}'::text[] NOT NULL,
"drives" text[] DEFAULT '{}'::text[] NOT NULL,
"transmissions" text[] DEFAULT '{}'::text[] NOT NULL,
"engine_l_min" real,
"engine_l_max" real,
"cylinders_min" smallint,
"cylinders_max" smallint,
"turbo" boolean,
"power_kw_min" real,
"power_kw_max" real,
"l100_min" real,
"l100_max" real,
"co2_min" smallint,
"co2_max" smallint,
"mpg_min" real,
"mpg_max" real,
"range_km_max" smallint,
"seats" smallint,
"doors" smallint,
"mass_kg" smallint,
"nl_count" integer DEFAULT 0 NOT NULL,
"nl_rank" integer,
"nl_first_year" smallint,
"nl_last_year" smallint,
"nl_price" integer,
"nl_price_latest" integer,
"nl_price_latest_year" smallint,
"nl_years" jsonb DEFAULT '[]' NOT NULL,
"nl_names" jsonb DEFAULT '[]' NOT NULL,
"us_years" smallint[] DEFAULT '{}'::smallint[] NOT NULL,
"variant_count" integer DEFAULT 0 NOT NULL,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cars"."variants" (
"id" serial PRIMARY KEY,
"model_id" integer NOT NULL,
"source" text DEFAULT 'fueleconomy.gov' NOT NULL,
"source_id" integer,
"year" smallint NOT NULL,
"name" text NOT NULL,
"engine_l" real,
"cylinders" smallint,
"transmission" text,
"drive" text,
"fuel" text,
"size_class" text,
"mpg_city" real,
"mpg_hwy" real,
"mpg_comb" real,
"co2_gpm" smallint,
"range_mi" smallint,
"ev_motor" text,
"turbo" boolean DEFAULT false NOT NULL,
"supercharged" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "brands_slug_key" ON "cars"."brands" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "models_dex_key" ON "cars"."models" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "models_brand_slug_key" ON "cars"."models" ("brand_id","slug");--> statement-breakpoint
CREATE INDEX "models_nl_count_idx" ON "cars"."models" ("nl_count");--> statement-breakpoint
CREATE INDEX "models_search_trgm_idx" ON "cars"."models" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "variants_model_year_idx" ON "cars"."variants" ("model_id","year");--> statement-breakpoint
ALTER TABLE "cars"."models" ADD CONSTRAINT "models_brand_id_brands_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "cars"."brands"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cars"."variants" ADD CONSTRAINT "variants_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/cars-import.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['brands','models'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON cars.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA cars TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cars TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cars TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA cars TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA cars TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('cars','brands','cars-brands','Car brands','core',false,false,1000),
('cars','models','cars-models','Car models','core',false,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
ALTER TABLE "house"."listings" ADD COLUMN "lbm_score" double precision;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_class" integer;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_onv_class" integer;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_level" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_precision" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_code" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_buurt_name" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_year" integer;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_detail" jsonb;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_query" text;--> statement-breakpoint
ALTER TABLE "house"."listings" ADD COLUMN "lbm_scored_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
CREATE SCHEMA "music";
--> statement-breakpoint
CREATE TABLE "music"."albums" (
"id" serial PRIMARY KEY,
"spotify_id" text,
"name" text NOT NULL,
"artist_id" integer,
"image_url" text,
"image_url_small" text,
"release_date" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "music"."artists" (
"id" serial PRIMARY KEY,
"spotify_id" text,
"name" text NOT NULL,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "music"."import_state" (
"key" text PRIMARY KEY,
"data" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "music"."now_playing" (
"id" smallint PRIMARY KEY DEFAULT 1,
"track_id" integer,
"is_playing" boolean DEFAULT false NOT NULL,
"progress_ms" integer,
"device_name" text,
"device_type" text,
"context_uri" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "now_playing_single_row" CHECK ("id" = 1)
);
--> statement-breakpoint
CREATE TABLE "music"."plays" (
"id" serial PRIMARY KEY,
"track_id" integer NOT NULL,
"played_at" timestamp with time zone NOT NULL,
"source" text DEFAULT 'spotify' NOT NULL,
"origin" text DEFAULT 'api' NOT NULL,
"ms_played" integer,
"context_type" text,
"context_uri" text,
"platform" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plays_source_check" CHECK ("source" in ('spotify', 'lastfm', 'youtube', 'local')),
CONSTRAINT "plays_origin_check" CHECK ("origin" in ('api', 'export', 'import', 'manual'))
);
--> statement-breakpoint
CREATE TABLE "music"."spotify_auth" (
"id" smallint PRIMARY KEY DEFAULT 1,
"spotify_user_id" text NOT NULL,
"display_name" text,
"refresh_token" text NOT NULL,
"access_token" text,
"access_expires_at" timestamp with time zone,
"scope" text,
"recent_cursor" bigint,
"needs_reauth" boolean DEFAULT false NOT NULL,
"lease_until" timestamp with time zone,
"last_poll_at" timestamp with time zone,
"last_error" text,
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spotify_auth_single_row" CHECK ("id" = 1)
);
--> statement-breakpoint
CREATE TABLE "music"."track_artists" (
"track_id" integer,
"artist_id" integer,
"position" smallint DEFAULT 0 NOT NULL,
CONSTRAINT "track_artists_pkey" PRIMARY KEY("track_id","artist_id")
);
--> statement-breakpoint
CREATE TABLE "music"."tracks" (
"id" serial PRIMARY KEY,
"spotify_id" text,
"name" text NOT NULL,
"album_id" integer,
"artist_id" integer,
"duration_ms" integer,
"explicit" boolean,
"match_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "albums_spotify_id_key" ON "music"."albums" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
CREATE INDEX "albums_name_idx" ON "music"."albums" (lower("name"));--> statement-breakpoint
CREATE UNIQUE INDEX "artists_spotify_id_key" ON "music"."artists" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
CREATE INDEX "artists_name_idx" ON "music"."artists" (lower("name"));--> statement-breakpoint
CREATE UNIQUE INDEX "plays_dedupe_key" ON "music"."plays" ("source","track_id","played_at");--> statement-breakpoint
CREATE INDEX "plays_played_at_idx" ON "music"."plays" ("played_at");--> statement-breakpoint
CREATE INDEX "plays_source_played_idx" ON "music"."plays" ("source","played_at");--> statement-breakpoint
CREATE INDEX "plays_track_played_idx" ON "music"."plays" ("track_id","played_at");--> statement-breakpoint
CREATE INDEX "track_artists_artist_idx" ON "music"."track_artists" ("artist_id");--> statement-breakpoint
CREATE UNIQUE INDEX "tracks_spotify_id_key" ON "music"."tracks" ("spotify_id") WHERE "spotify_id" is not null;--> statement-breakpoint
CREATE INDEX "tracks_name_idx" ON "music"."tracks" (lower("name"));--> statement-breakpoint
CREATE INDEX "tracks_match_key_idx" ON "music"."tracks" ("match_key");--> statement-breakpoint
ALTER TABLE "music"."albums" ADD CONSTRAINT "albums_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "music"."now_playing" ADD CONSTRAINT "now_playing_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "music"."plays" ADD CONSTRAINT "plays_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fkey" FOREIGN KEY ("track_id") REFERENCES "music"."tracks"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "music"."track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fkey" FOREIGN KEY ("album_id") REFERENCES "music"."albums"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "music"."tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE SET NULL;--> statement-breakpoint
-- Hand-written below. Cross-source dedupe: every stat reads plays_deduped, which hides a
-- Last.fm scrobble when Spotify (live API or the streaming-history export) has the same
-- normalised artist+title around the same time. Last.fm stores when a track started,
-- Spotify when it ended, so the Spotify end may fall from 3 min before the scrobble to
-- track length + 3 min after it. Spotify rows always win; Last.fm fills the gaps.
CREATE VIEW "music"."plays_deduped" AS
SELECT p.* FROM "music"."plays" p
WHERE p.source <> 'lastfm' OR NOT EXISTS (
SELECT 1
FROM "music"."tracks" lt, "music"."plays" s
JOIN "music"."tracks" st ON st.id = s.track_id
WHERE lt.id = p.track_id
AND s.source = 'spotify'
AND s.played_at >= p.played_at - interval '3 minutes'
AND s.played_at <= p.played_at + interval '25 minutes'
AND s.played_at <= p.played_at + make_interval(secs => coalesce(st.duration_ms, 600000) / 1000.0) + interval '3 minutes'
AND st.match_key = lt.match_key
);
--> statement-breakpoint
-- Grants only run on prod, where migrations run as `personal`. Listening history is public
-- (personal_ro may read it); the Spotify refresh token is for the app role only.
REVOKE ALL ON SCHEMA music 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 music TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA music TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA music
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA music TO personal_ro;
GRANT SELECT ON music.artists, music.albums, music.tracks, music.track_artists, music.plays,
music.plays_deduped, music.now_playing TO personal_ro;
END IF;
-- Optional host-side poller (deploy/music-collector). The role is created out of band
-- (it has a password); see deploy/music-collector/README.md.
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'music_collector') THEN
GRANT USAGE ON SCHEMA music TO music_collector;
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
music.plays, music.now_playing, music.import_state TO music_collector;
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
ALTER TABLE "tasks"."tasks" ADD COLUMN "auto_handle" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
CREATE TABLE "music"."osu_match_cache" (
"key" text PRIMARY KEY,
"result" jsonb NOT NULL,
"searched_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN IF current_user = 'personal' THEN GRANT SELECT, INSERT, UPDATE, DELETE ON music.osu_match_cache TO personal_app; END IF; END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
CREATE TABLE "cars"."photos" (
"id" serial PRIMARY KEY,
"model_id" integer NOT NULL,
"position" smallint NOT NULL,
"kind" text NOT NULL,
"file" text NOT NULL,
"caption" text,
"thumb" text NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
"author" text,
"license" text,
"license_url" text
);
--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "wikidata" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "wikipedia" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "logo" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "logo_file" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "logo_author" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "logo_license" text;--> statement-breakpoint
ALTER TABLE "cars"."brands" ADD COLUMN "photo" text;--> statement-breakpoint
ALTER TABLE "cars"."models" ADD COLUMN "wikidata" text;--> statement-breakpoint
ALTER TABLE "cars"."models" ADD COLUMN "wikipedia" text;--> statement-breakpoint
ALTER TABLE "cars"."models" ADD COLUMN "photo" text;--> statement-breakpoint
ALTER TABLE "cars"."models" ADD COLUMN "photo_count" smallint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "cars"."models" ADD COLUMN "photos_checked_at" timestamp with time zone;--> statement-breakpoint
CREATE UNIQUE INDEX "photos_model_position_key" ON "cars"."photos" ("model_id","position");--> statement-breakpoint
ALTER TABLE "cars"."photos" ADD CONSTRAINT "photos_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- hand-written: grants + registry for the photos table
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON cars.photos TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE cars.photos_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON cars.photos TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('cars','photos','cars-photos','Car photos','core',false,false,1000)
ON CONFLICT DO NOTHING;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
ALTER TABLE "music"."spotify_auth" ADD COLUMN "lease_holder" text;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,290 @@
-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
-- schema to the Drizzle model, and creates it from scratch on an empty database.
CREATE SCHEMA IF NOT EXISTS "hardware";
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"name" text NOT NULL UNIQUE,
"label" text,
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"brand" text,
"model" text,
"specs" jsonb DEFAULT '{}' NOT NULL,
"price_cents" integer,
"monthly_cents" integer,
"budget_cents" integer,
"shop" text,
"purchase_date" date,
"url" text,
"image_url" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."components" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"device_id" bigint NOT NULL,
"category" text NOT NULL,
"brand" text,
"model" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"specs" jsonb DEFAULT '{}' NOT NULL,
"unit_price_cents" integer,
"total_price_cents" integer,
"shop" text,
"order_number" text,
"order_date" date,
"warranty_until" date,
"invoice_number" text,
"status" text DEFAULT 'in_use' NOT NULL,
"verified" boolean DEFAULT true NOT NULL,
"dpi" integer,
"measured_dpi" integer,
"url" text,
"image_url" text,
"notes" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"game" text NOT NULL UNIQUE,
"cm_per_360" numeric(7,2),
"yaw" numeric(10,6),
"in_game_sens" numeric(10,4),
"mouse_id" bigint,
"fov" text,
"notes" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
);
--> statement-breakpoint
-- Existing tables: new columns.
ALTER TABLE "hardware"."devices"
ADD COLUMN IF NOT EXISTS "label" text,
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
ADD COLUMN IF NOT EXISTS "brand" text,
ADD COLUMN IF NOT EXISTS "model" text,
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
ADD COLUMN IF NOT EXISTS "price_cents" integer,
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
ADD COLUMN IF NOT EXISTS "shop" text,
ADD COLUMN IF NOT EXISTS "purchase_date" date,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text,
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
--> statement-breakpoint
ALTER TABLE "hardware"."components"
ADD COLUMN IF NOT EXISTS "dpi" integer,
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text;
--> statement-breakpoint
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
--> statement-breakpoint
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
-- device FK -> Drizzle's name.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
--> statement-breakpoint
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END IF;
END LOOP;
END $$;
--> statement-breakpoint
DROP VIEW IF EXISTS "hardware"."setup_overview";
--> statement-breakpoint
CREATE VIEW "hardware"."setup_overview" AS
SELECT d.name AS device,
d.label AS device_label,
d.kind AS device_kind,
d.status AS device_status,
c.category,
c.brand,
c.model,
c.quantity,
c.total_price_cents,
round(c.total_price_cents::numeric / 100.0, 2) AS total_price_eur,
c.shop,
c.order_date,
c.warranty_until,
(c.warranty_until >= current_date) AS under_warranty,
c.status,
c.verified,
c.specs,
c.dpi,
c.measured_dpi,
c.notes,
c.order_number,
c.invoice_number,
c.source,
c.id AS component_id
FROM hardware.components c
JOIN hardware.devices d ON d.id = c.device_id
ORDER BY d.sort_order, d.name,
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
c.order_date NULLS LAST, c.model;
--> statement-breakpoint
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA hardware TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA hardware TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;
--> statement-breakpoint
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
-- unknowns stay null. No home address in public-facing notes.
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
shop = 'Megekko', sort_order = 10,
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
WHERE name = 'stan-pc' AND label IS NULL;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
purchase_date = '2021-01-31', sort_order = 90
WHERE name = 'old-pc-2021' AND label IS NULL;
--> statement-breakpoint
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
'Previous phone, kept (weak battery and speed).'),
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
'With a keyboard case; used for typing on the go and Termius SSH.'),
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
-- Peripherals get real categories instead of "accessory".
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
--> statement-breakpoint
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
--> statement-breakpoint
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
--> statement-breakpoint
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
--> statement-breakpoint
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
FROM (VALUES
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
JOIN hardware.devices d ON d.name = v.device
ON CONFLICT (device_id, model, order_number) DO NOTHING;
--> statement-breakpoint
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
('Counter-Strike 2', 0.022, 10),
('Call of Duty', NULL, 20),
('osu!', NULL, 30),
('Sea of Thieves', NULL, 40)
ON CONFLICT (game) DO NOTHING;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
CREATE SCHEMA "planes";
--> statement-breakpoint
CREATE TABLE "planes"."makers" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"full_name" text,
"country" text,
"wikidata" text,
"wikipedia" text,
"type_count" integer DEFAULT 0 NOT NULL,
"first_year" smallint,
"last_year" smallint,
"built_total" integer DEFAULT 0 NOT NULL,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"logo" text,
"logo_file" text,
"logo_author" text,
"logo_license" text,
"photo" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "planes"."photos" (
"id" serial PRIMARY KEY,
"type_id" integer NOT NULL,
"position" smallint NOT NULL,
"kind" text NOT NULL,
"file" text NOT NULL,
"caption" text,
"thumb" text NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
"author" text,
"license" text,
"license_url" text
);
--> statement-breakpoint
CREATE TABLE "planes"."types" (
"id" serial PRIMARY KEY,
"dex" integer NOT NULL,
"maker_id" integer NOT NULL,
"family_id" integer,
"slug" text NOT NULL,
"name" text NOT NULL,
"full_name" text NOT NULL,
"search" text NOT NULL,
"wikidata" text NOT NULL,
"wikipedia" text,
"kind" text,
"description" text,
"summary" text,
"role" text,
"roles" text[] DEFAULT '{}'::text[] NOT NULL,
"role_label" text,
"country" text,
"first_flight" text,
"first_flight_year" smallint,
"introduced_year" smallint,
"retired_year" smallint,
"status" text,
"in_service" boolean,
"number_built" integer,
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
"engine_type" text,
"engine_count" smallint,
"engines" text[] DEFAULT '{}'::text[] NOT NULL,
"crew" smallint,
"passengers" smallint,
"length_m" real,
"wingspan_m" real,
"height_m" real,
"rotor_m" real,
"mtow_kg" integer,
"max_speed_kmh" smallint,
"cruise_kmh" smallint,
"range_km" integer,
"ceiling_m" integer,
"wing" text,
"gear" text,
"icao" text[] DEFAULT '{}'::text[] NOT NULL,
"recognize" text,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"photo" text,
"photo_count" smallint DEFAULT 0 NOT NULL,
"photos_checked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "makers_slug_key" ON "planes"."makers" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_type_position_key" ON "planes"."photos" ("type_id","position");--> statement-breakpoint
CREATE UNIQUE INDEX "types_dex_key" ON "planes"."types" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "types_wikidata_key" ON "planes"."types" ("wikidata");--> statement-breakpoint
CREATE UNIQUE INDEX "types_maker_slug_key" ON "planes"."types" ("maker_id","slug");--> statement-breakpoint
CREATE INDEX "types_number_built_idx" ON "planes"."types" ("number_built");--> statement-breakpoint
CREATE INDEX "types_first_flight_year_idx" ON "planes"."types" ("first_flight_year");--> statement-breakpoint
CREATE INDEX "types_search_trgm_idx" ON "planes"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
ALTER TABLE "planes"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "planes"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "planes"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/planes-import.ts and scripts/planes-photos.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['makers','types'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON planes.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA planes TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA planes TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA planes TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA planes TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA planes TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('planes','makers','planes-makers','Plane makers','core',false,false,1000),
('planes','types','planes-types','Plane types','core',false,false,1000),
('planes','photos','planes-photos','Plane photos','core',false,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
CREATE SCHEMA "wildlife";
--> statement-breakpoint
CREATE TABLE "wildlife"."groups" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"name_nl" text NOT NULL,
"emoji" text NOT NULL,
"sort_order" smallint DEFAULT 0 NOT NULL,
"species_count" integer DEFAULT 0 NOT NULL,
"nl_count" integer DEFAULT 0 NOT NULL,
"photo" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wildlife"."photos" (
"id" serial PRIMARY KEY,
"species_id" integer NOT NULL,
"position" smallint NOT NULL,
"kind" text NOT NULL,
"photo_id" integer NOT NULL,
"url" text NOT NULL,
"width" integer,
"height" integer,
"author" text,
"license" text NOT NULL,
"page_url" text,
"place" text
);
--> statement-breakpoint
CREATE TABLE "wildlife"."species" (
"id" serial PRIMARY KEY,
"dex" integer NOT NULL,
"group_id" integer NOT NULL,
"slug" text NOT NULL,
"scientific_name" text NOT NULL,
"name_en" text,
"name_nl" text,
"search" text NOT NULL,
"inat_id" integer NOT NULL,
"gbif_key" integer,
"wikidata" text,
"wikipedia_en" text,
"wikipedia_nl" text,
"class_name" text,
"order_name" text,
"family_name" text,
"iucn" text,
"nl_status" text,
"nl_establishment" text,
"extinct" boolean DEFAULT false NOT NULL,
"nl_obs" integer DEFAULT 0 NOT NULL,
"europe_obs" integer DEFAULT 0 NOT NULL,
"world_obs" integer DEFAULT 0 NOT NULL,
"gbif_nl" integer,
"scope" text NOT NULL,
"summary" text,
"summary_nl" text,
"facts" text[] DEFAULT '{}'::text[] NOT NULL,
"mass_kg" real,
"length_m" real,
"lifespan_years" real,
"named_year" smallint,
"named_by" text,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"photo" text,
"photo_count" smallint DEFAULT 0 NOT NULL,
"photos_checked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "groups_slug_key" ON "wildlife"."groups" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_species_position_key" ON "wildlife"."photos" ("species_id","position");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_species_photo_key" ON "wildlife"."photos" ("species_id","photo_id");--> statement-breakpoint
CREATE UNIQUE INDEX "species_dex_key" ON "wildlife"."species" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "species_inat_key" ON "wildlife"."species" ("inat_id");--> statement-breakpoint
CREATE UNIQUE INDEX "species_slug_key" ON "wildlife"."species" ("slug");--> statement-breakpoint
CREATE INDEX "species_group_idx" ON "wildlife"."species" ("group_id");--> statement-breakpoint
CREATE INDEX "species_nl_obs_idx" ON "wildlife"."species" ("nl_obs");--> statement-breakpoint
CREATE INDEX "species_search_trgm_idx" ON "wildlife"."species" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
ALTER TABLE "wildlife"."photos" ADD CONSTRAINT "photos_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "wildlife"."species" ADD CONSTRAINT "species_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "wildlife"."groups"("id") ON DELETE CASCADE;--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['groups','species'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON wildlife.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA wildlife TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wildlife TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA wildlife TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA wildlife TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA wildlife TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('wildlife','groups','wildlife-groups','Wildlife groups','core',false,false,1000),
('wildlife','species','wildlife-species','Wildlife species','core',false,false,1000),
('wildlife','photos','wildlife-photos','Wildlife photos','core',false,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

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