Software developer · Denmark

PeterJespersen

I build products end to end — storefronts, trading bots, data platforms, music apps. Below: every commit I've made, on two GitHub accounts, as a city.

2017 → today

contributions · 0 active days · 2 accounts · 1 developer

github.com/piijt + github.com/peterhjespersen

Two accounts.
One graph.

Side projects on my personal account, product work on my work account. Merged day by day, every year since 2017.

live Case study · 2019 → today · solo-built

Trade your CS2, DOTA2, RUST and TF2 skins for World of Warcraft gold. I designed, built and run the whole platform — storefront, API, Steam trading bots and pricing engine.

live in production since
2019
orders completed
7,000+
gold given away in giveaways
25M
Trustpilot reviews
158
trade-raid.com/trade
Trade-Raid trade page: Steam inventory, trading details and WoW game version picker
trade-raid.com
Trade-Raid landing page with the animated how-it-works stage
Trade-Raid on a phone
Skins → gold, priced liveBot sends the Steam offer

The core loop

One trade, four steps, zero middlemen.

Under the hood

Every layer, built and run by me.

  1. StorefrontNuxt 4 · Vue 3 · TS
  2. APIExpress · MongoDB · pricing
  3. Trade botssteam-tradeoffer-manager
  4. SteamSteam Web API

Steam trading-bot service

A separate service that sends trade offers to customers with steam-tradeoffer-manager and follows each one through to an accepted trade.

Pricing engine

Converts Steam market prices into gold per game version and region, layered with event bonus percentages and a +10% bucket bonus for selecting more than 10 items.

Stacked items

Rust-style stacked items are handled as quantities, so a stack is priced, selected and traded correctly instead of as a single skin.

  • Nuxt 4
  • Vue 3
  • TypeScript
  • Node.js
  • Express
  • MongoDB
  • Steam Web API
  • Docker
See it live

Side project · music

flow

Your music, saved and always playing.

A self-hosted music library: search YouTube or import a Spotify playlist, build playlists, and play it all from your phone with the screen off. That last part drives the entire architecture.

  • Nuxt 4 SPA
  • shadcn-vue
  • Tailwind 4
  • FastAPI
  • yt-dlp
  • MongoDB
  • MediaSession API
  • Docker + Caddy
0
YouTube iframesa locked phone kills them
1
<audio> elementmounted once, never re-created
206
Partial ContentRange support written by hand
0
Node runtimes in prodstatic SPA served by FastAPI
9:41

Good evening

Recently addedShow all
Neon TidesHarbor Lights

Friday 9 October

9:41

Neon TidesHarbor Lights
0:37−3:05

Live recreation · tap anything

Background playback is the whole point.

A tiny synth built live with WebAudio, kept quiet. While it plays, the visualizer reads a real AnalyserNode instead of the synthetic beat.

Architecture

From a search box to a locked phone.

Every box is a decision the lock screen forced. Tap one for the detail that makes it work.

YouTubesearch · linksSpotifyplaylist importFastAPIyt-dlp as a librarym4a / AACreal files on diskMongoDBmetadata onlyHTTP 206Range requestsOne originstatic SPA + API<audio>one, permanentMediaSessionOS controls + artLock screenstill playing

YouTube

Search that pages properly

yt-dlp has no continuation cursor, so a deeper page means asking for a larger N. Each query is cached for 10 minutes, over-read by 40, and merged rather than replaced, so pages never overlap when YouTube reshuffles.

ytsearchN: · LOOKAHEAD 40 · merge, don't replace
  • audio bytes
  • metadata
  • matching · control

Spotify import

Matching songs, not guessing.

Each Spotify track is searched on YouTube and every candidate is scored, with duration weighted most because a different length almost always means a different recording. Below 0.40 is reported as not found; below 0.72 is flagged for you to check. This runs the real formula.

Spotify playlistLate Night Drives5 songs · syncs in place on re-import
  1. 1Neon TidesHarbor Lights · 3:42scoring
  2. 2Paper SatellitesMira Vale · 4:05queued
  3. 3Slow BloomOdd Fjord · 3:18queued
  4. 4GlasshouseKite Theory · 2:56queued
  5. 5Untitled 7Sora Wren · 3:33queued

3 matched · 1 to check · 1 not found → a matching flow playlist, name and cover included

  • Play before you save

    Every search result plays as a preview within seconds. Add promotes it in place, no second download; anything you don't keep is swept after a few hours.

  • Video, slaved to the audio

    Opt-in video per track. The <audio> stays the clock: big gaps snap into place, small drift is absorbed by nudging playbackRate a few percent. Toggling never interrupts playback.

  • Built for other people

    Follow people, share your library per follower, follow their playlists, see friend activity and join them at their position. Messaging needs a follow both ways.

  • Real accounts

    argon2 hashes in MongoDB. The admin is seeded on startup; everyone else joins through an expiring invite link generated on the Admin page.

  • One container, one origin

    Docker Compose for the app and MongoDB, Caddy for automatic HTTPS. The SPA is prebuilt and served by FastAPI, so there is no Node runtime in production.

  • Installs like an app

    Add to home screen over HTTPS and it behaves like a native player. The service worker caches only the shell and hashed assets, never /api/.

Product · data · SaaS

A multi-tenant data workspace I designed and built end to end. Point it at MySQL, Postgres or MongoDB, save a query, and every run is captured as an immutable, versioned snapshot: diffable, schedulable and ready to chart. Every row, query and API key is fenced off by organization.

  • Nuxt 3
  • Vue 3
  • TypeScript
  • Tailwind
  • shadcn-vue
  • ECharts
  • Express
  • Mongoose
  • MongoDB 7
  • MySQL 8
  • Postgres 16
  • Stripe
  • Docker
  • Caddy
  1. 013 enginesMySQL, Postgres and MongoDB connections, plus HTTP APIs and CSV / JSON uploads.
  2. 02VersionedSchema inferred, rows coerced, deduplicated by dataHash. Every run is a new snapshot.
  3. 03ScheduledConnection-backed snapshots auto-refresh on an interval, hands-free.
  4. 04Multi-tenantorgId on every model and in the JWT. Four roles, invites, Stripe trials.
connect → query → capture auto-refresh · every 15 min
MySQL8.0 · shopPostgres16 · billingMongoDB7 · eventsSAVED QUERYorders_by_regionDATASET · VERSIONED SNAPSHOTSorders_by_regionv86 rows · #8e9157fmg · 11:00✓ dedupedorders_by_regionv96 rows · #b608c21my · 11:15✓ dedupedorders_by_regionv107 rows · #a3cf8efpg · 11:30✓ dedupedorders_by_regionv116 rows · #0a399b5mg · 11:45✓ dedupedorders_by_regionv126 rows · #bf36ce1my · 12:00✓ deduped
SELECT region, COUNT(*) AS orders, SUM(total) AS revenue, status FROM orders GROUP BY region, status; read-only ✓
0/32 rows

Click a source to push a burst of rows · click a card to open that version

localhost:3000/dashboards/ops-overviewlive
Dashboards / Ops overview
orders_by_region · v12
Trial · 2 days leftAdd widget

Total orders

1,222
sum(orders)

Revenue

$53,035
sum(revenue)

Avg order value

$43.40
avg(revenue / orders)

Revenue across snapshots · last 10 versions

45.0k53.3k61.7k70.0kv3v4v5v6v7v8v9v10v11v12

Orders by status

81%paid
  • paid991
  • pending0
  • refunded231

Orders by region

289EU-West
277APAC
133Nordics
231US-West
208LATAM
84MEA

Top regions · sum(revenue)

regionsum(revenue)
APAC$12,314
US-West$12,303
EU-West$10,706
LATAM$9,183
Nordics$5,365
Version history
v12
v1v12
v11→v12+0−0~112:00 · #bf36ce1
regionordersrevenuestatus
EU-West28910.7kpaid
APAC27712.3kpaid
Nordics1335.4kpaid
~US-West1782318.0k12.3krefunded
LATAM2089.2kpaid
MEA843.2kpaid

schema region:string · orders:int · revenue:float · status:enum

Tenant isolationevery query filtered by orgId
JWTyouAPIrequireAuthscope by orgId404Acme Co128 snapshotssigned inNorthwind64 snapshotsGlobex211 snapshots
owneradmineditorviewer
// decoded JWT
{ "sub": "usr_4c1e", "role": "editor", "orgId": "org_acme" }

Click an organization to sign in as it