- JavaScript 47.8%
- C# 27%
- CSS 23.8%
- Dockerfile 0.9%
- HTML 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| chelines.client | ||
| chelines.Server | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
chelines
Satirical office "cryptocurrency" disguised as a swear jar. The chelÃn fluctuates against the MXN like a real coin, driven by Bitcoin, the USD/MXN rate, the weather in CDMX, Daimler Truck's stock, and the size of the office's guardadito. Laura is the central bank.
Stack
| Layer | Choice |
|---|---|
| Backend | ASP.NET Core 8 Web API |
| Storage | Single JSON file at App_Data/chelin.json |
| Frontend | React 18 + Vite (JavaScript), NES.css + Press Start 2P + VT323 |
| Coupling | Microsoft SpaProxy â server hosts the SPA, single dev URL |
| External feeds | CoinGecko (BTC), Frankfurter (USD/MXN), Open-Meteo (CDMX temp), Yahoo Finance (Daimler Truck DTRUY) â all keyless |
How it works
Price engine
A BackgroundService ticks every 30 s. Every 5 min it refreshes the external feeds (cached in between), then computes:
mxn = BasePrice # 10.0 at neutral (Pricing:BasePrice, env-tweakable)
à btcMod # 1 + (btc24hÎ/100 à 1.2)
à fxMod # 1 + ((usdmxn â 18)/18 à 0.5)
à weatherMod # â¥25°C â 1.06, â¤12°C â 0.94, else 1.0
à dtgMod # 1 + (dtg24hÎ/100 à 0.6) â Daimler Truck (DTRUY) ADR
à supplyMod # see below
à chaosMod # evento lever, drifts toward target, default 1.0
à (1 ± noise) # ±NoiseAmplitude jitter (prod 6%)
Each tick is appended to a ring buffer (last ~24 h kept). Mutations from the admin (multiplier change, fine creation, payment toggle) trigger an immediate recompute so the UI reacts without waiting for the next timer.
Supply mod (jar backing)
Only paid fines back the currency. The curve is sqrt-shaped, calibrated against an internal anchor of 5,000 â¡, capped at +50%:
| paid chelines | bonus |
|---|---|
| 50 | +5.0% |
| 150 | +8.7% |
| 500 | +15.8% |
| 1,000 | +22.4% |
| 2,500 | +35.4% |
| 5,000+ | +50.0% |
Same shape as the pixel jar's fill animation, so the visual matches the math.
Fines
Every fine is {targetName, amount, reason?, paid, createdAt, paidAt?}. Only Laura can create, mark paid/unpaid, or delete. Public Home shows the most recent 3 with a modal expander for the full history; the guardadito card splits paid (backs the price, fills the jar) from unpaid (shown as "por cobrar").
Run locally
Requirements: .NET 8 SDK, Node 18+, npm.
# from repo root, one-time
cd chelines.client && npm install && cd ..
# from anywhere
dotnet run --project chelines.Server --launch-profile https
The launch profile starts Kestrel on https://localhost:7128 and auto-spawns Vite at https://localhost:63446 via SpaProxy. Open either URL â both serve the same app in dev. Self-signed cert; accept it once per browser.
Admin
Magic URL: https://localhost:7128/admin/<Admin:Secret>. The default dev secret is change-me-in-prod (in appsettings.Development.json). Override per environment via Admin:Secret config or the Admin__Secret env var.
Laura's panel, top to bottom:
- CAJA â read-only summary of current cotización + paid/pending totals
- NUEVA MULTA â name, optional reason, amount (with quick-tap presets), "ya pagó" checkbox
- MULTAS â full list with "marcar pagado / pendiente" toggle and à delete
- PALANCA â central-bank multiplier (Ã 0.01â100)
Every admin mutation triggers an immediate price recompute.
Configuration
chelines.Server/appsettings.json (dev secrets in appsettings.Development.json):
{
"Storage": { "Path": "App_Data/chelin.json" },
"Pricing": {
"Interval": "00:00:30", // tick cadence
"FeedRefresh": "00:05:00", // external feed cache TTL
"RetainTicks": 2880, // ~24h at 30s
"BasePrice": 10.0, // MXN per chelÃn at neutral
"NoiseAmplitude": 0.02, // ±2% per tick
"Daimler": { "Symbol": "DTRUY" } // Yahoo ticker â DTRUY is the US ADR for Daimler Truck Holding AG
},
"Admin": { "Secret": "" }
}
Env-var overrides use double underscores: Pricing__Interval, Admin__Secret, etc. In prod, compose exposes two convenience knobs read from .env â CHELINES_BASE_PRICE (default 10) and CHELINES_NOISE (default 0.06) â so the base value and volatility can be retuned with just an edit + docker compose up -d --force-recreate, no rebuild.
Storage
Single JSON file, gitignored under App_Data/. Schema:
{
"lauraMultiplier": 1.0,
"lauraSetAt": "...",
"fines": [ { "id": "...", "targetName": "...", "amount": 1, "reason": "...", "paid": false, "createdAt": "...", "paidAt": null } ],
"ticks": [ { "mxn": 5.12, "at": "...", "inputs": {...}, "supplyTotal": 0, "lauraMultiplier": 1.0 } ]
}
A legacy jarChelines integer is migrated on first read into a single synthetic paid fine so existing totals carry over. Delete the file to start fresh.
API
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/chelin |
public | Latest snapshot (mxn, change24h, jar, inputs) |
| GET | /api/chelin/history?limit=N |
public | Last N price ticks for the sparkline |
| POST | /api/chelin/multiplier |
secret | {secret, multiplier} |
| GET | /api/fines?limit=N |
public | List fines, newest first |
| POST | /api/fines |
secret | {secret, targetName, amount, reason?, paid} |
| POST | /api/fines/{id}/pay |
secret | {secret} |
| POST | /api/fines/{id}/unpay |
secret | {secret} |
| POST | /api/fines/{id}/delete |
secret | {secret} |
All admin endpoints validate Admin:Secret from the body; no auth header, no cookies.
Project layout
chelines/
âââ chelines.Server/ # ASP.NET Core 8 Web API
â âââ Domain/ # Records: Fine, PriceTick, PriceInputs, JarState, ChelinSnapshot
â âââ Storage/ # IChelinStore + JsonFileChelinStore
â âââ Pricing/ # PriceCalculator, PriceUpdater, PriceTickService, Feeds/
â âââ Controllers/ # ChelinController, FinesController
â âââ App_Data/ # JSON store (gitignored)
â âââ Program.cs
âââ chelines.client/ # React 18 SPA
â âââ src/
â â âââ components/ # PixelArt, Coin, Flag, Jar, Ticker, Sparkline,
â â â # Change24h, ExchangeBoard, InputsCard, FinesFeed
â â âââ pages/ # Home, Admin
â â âââ lib/ # api.js, rates.js
â â âââ App.jsx, main.jsx, index.css
â â âââ ...
â âââ public/coin.svg # favicon
â âââ vite.config.js
âââ Dockerfile # multi-stage: node SPA build + dotnet publish + aspnet runtime
âââ docker-compose.yml # single service, named volume for App_Data
âââ .env.example
âââ README.md
Deploy
Two-container compose on the VPS: the chelines app and a Cloudflare Tunnel sidecar that publishes it at chelines.chambatorio.com without opening any inbound ports on the host. Built locally with Podman (rootless); the VPS runs Docker.
Quick reference:
# laptop (podman)
podman build -t chelines:latest .
podman save chelines:latest | gzip | ssh chelines 'gunzip | docker load && docker tag localhost/chelines:latest chelines:latest'
# VPS (docker)
ssh chelines 'cd /srv/chelines && docker compose up -d --force-recreate'
Two traps, both load-bearing:
- Build with
podman builddirectly, notpodman compose build. The compose file's runtimeenvironment:block has${CHELINES_ADMIN_SECRET:?â¦}/${CLOUDFLARE_TUNNEL_TOKEN:?â¦}guards that podman-compose evaluates at parse time, so a build with no.envpresent aborts before doing anything. The Dockerfile references none of those vars â they're runtime-only â so building straight from it is both correct and avoids needing secrets on the build host. On the VPS,docker compose up -dworks because/srv/chelines/.envsupplies both values. - Retag after
docker load. Podman saves the image namespaced aslocalhost/chelines:latest, but compose references plainchelines:latest. Without thedocker tagstep the load lands under the wrong name and compose silently keeps running the old image â the deploy looks successful but nothing changes.--force-recreatethen guarantees the container is rebuilt from the freshly tagged image.
Verify the deploy actually took â the built: date should be today:
ssh chelines 'docker image inspect --format "built: {{.Created}}" chelines:latest && docker ps --filter name=chelines --format "{{.Status}}"'
The runtime image is ASP.NET 8 + the prebuilt SPA in wwwroot/. State persists in a named volume mounted at /app/App_Data. Required prod env (in /srv/chelines/.env): CHELINES_ADMIN_SECRET, CLOUDFLARE_TUNNEL_TOKEN. The tunnel hits chelines:8080 over the container network; no published host ports.
Container hardening: cap_drop: [ALL], no-new-privileges, read-only rootfs (only /tmp tmpfs and App_Data named volume are writable). Host has the Copy Fail (algif_aead) and Dirty Frag (esp4/esp6/rxrpc) modules blacklisted.