Joke webapp for the office (A digital version of the swear jar)
  • JavaScript 47.8%
  • C# 27%
  • CSS 23.8%
  • Dockerfile 0.9%
  • HTML 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Axel 78e6c00058
All checks were successful
Deploy / deploy (push) Successful in 1m14s
feat(calc): chelin currency calculator between fines and exchange board
2026-07-14 09:49:25 -06:00
.forgejo/workflows ci: auto build + deploy image to apps1 on push to main 2026-06-22 20:30:36 -06:00
chelines.client feat(calc): chelin currency calculator between fines and exchange board 2026-07-14 09:49:25 -06:00
chelines.Server fix(fines): keep MXN amount absolute, let chelin count be fractional 2026-07-14 09:31:22 -06:00
.dockerignore feat(deploy): dockerfile, compose and build helpers 2026-05-09 14:08:43 -06:00
.env.example feat(deploy): cloudflared sidecar, container hardening, kernel cve note 2026-05-09 15:28:42 -06:00
.gitignore feat(deploy): dockerfile, compose and build helpers 2026-05-09 14:08:43 -06:00
docker-compose.yml feat(pricing): recenter chelín to ~10 MXN, widen swings, make base config-bound 2026-06-11 20:13:31 -06:00
Dockerfile feat(deploy): cloudflared sidecar, container hardening, kernel cve note 2026-05-09 15:28:42 -06:00
README.md docs: deploy playbook + pricing formula/base updates 2026-06-11 20:13:31 -06:00

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 build directly, not podman compose build. The compose file's runtime environment: block has ${CHELINES_ADMIN_SECRET:?…} / ${CLOUDFLARE_TUNNEL_TOKEN:?…} guards that podman-compose evaluates at parse time, so a build with no .env present 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 -d works because /srv/chelines/.env supplies both values.
  • Retag after docker load. Podman saves the image namespaced as localhost/chelines:latest, but compose references plain chelines:latest. Without the docker tag step the load lands under the wrong name and compose silently keeps running the old image — the deploy looks successful but nothing changes. --force-recreate then 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.