Public portfolio showcase and community dashboard for the dividend-portfolio ecosystem. Built with Elixir/Phoenix LiveView to practice OTP patterns (GenServers, Supervisors, Registry).
Internet
|
+-------------+-------------+
| | |
quantic.es pulse.quantic.es logos.quantic.es
| | |
+---------+--+ +-------+------+ +---+--------+
| Rails App | | Pulse (this) | | Logo |
| | | Phoenix | | Service |
| - Auth | | LiveView | | (Go) |
| - Radar | | | +------------+
| - Holdings | | - Public |
| - Buy Plan | | portfolios |
+-----+------+ | - Community |
| | dashboard |
| +------+-------+
| |
+---+ +---------+
| |
+---+--+---+
| NATS |
| JetStream|
+----------+
Rails App (quantic.es) — main app with user auth, stock radar, transactions, dividends, and holdings management. Publishes events to NATS when portfolio data changes.
Pulse (pulse.quantic.es) — this app. Consumes NATS events and serves public portfolio pages and a real-time community dashboard. No database — state is held in-memory via GenServers and ETS.
NATS — lightweight messaging server (~10MB RAM). Runs as a Docker container on the same VPS. JetStream enabled for persistent event streams. Environment isolation via subject prefixes (prod., beta., dev.).
Logo Service (logos.quantic.es) — Go microservice for company logo images.
Rails publishes:
{env}.portfolio.updated {version: 2, slug, base_currency,
holdings: [{symbol, currency, quantity, avg_price,
price, value_in_base, value_in_usd}, ...]}
{env}.portfolio.opted_in {version: 2, slug, base_currency, holdings: [...]}
{env}.portfolio.opted_out {slug}
{env}.stock.price_updated {symbol, price, change_percent}
Pulse consumes -> updates GenServer state -> pushes to LiveView via PubSub
value_in_base is the holding value in the user's preferred display currency
(used by /p/:slug allocations); value_in_usd is the cross-portfolio
normalisation key the community dashboard sums on so a mix of bases doesn't
double-count. Older Rails deploys may emit the v1 shape (a bare holdings list
with no version field) — Pulse handles both, falling back to
quantity * price when the v2 fields are absent.
Pulse.Supervisor (one_for_one)
|
+-- PulseWeb.Telemetry Telemetry metrics
+-- DNSCluster DNS-based clustering (production)
+-- Phoenix.PubSub Internal pub/sub for LiveView updates
|
+-- Pulse.PortfolioRegistry Registry: slug -> worker PID lookup
+-- Pulse.PortfolioSupervisor DynamicSupervisor for portfolio workers
| |
| +-- PortfolioWorker("alice") GenServer: holds Alice's portfolio
| +-- PortfolioWorker("bob") GenServer: holds Bob's portfolio
| +-- ... One per opted-in user
|
+-- Pulse.Nats.Connection Supervised NATS connection (Gnat)
+-- Pulse.Nats.Consumer Subscribes to NATS subjects, dispatches events
|
+-- PulseWeb.Endpoint Phoenix HTTP server (Bandit)
Each PortfolioWorker is a GenServer that:
- Starts when a
portfolio.opted_inevent arrives — theNats.ConsumertellsPortfolioSupervisorto start a child - Holds state — current holdings, base currency, and computed metrics: allocation percentages, total value (in the user's base), total value in USD (for community aggregation), holding count
- Updates on
portfolio.updatedevents — recomputes metrics and broadcasts to PubSub - Serves reads — LiveView pages call
PortfolioWorker.get_portfolio(slug)via the Registry - Stops when a
portfolio.opted_outevent arrives — the supervisor terminates the child
The PortfolioRegistry allows O(1) lookup of worker processes by slug, so LiveView pages can find the right GenServer without querying a database.
/—DashboardLive— Community dashboard showing aggregate stats across all shared portfolios. Subscribes to PubSub"dashboard"topic for real-time updates./p/:slug—PortfolioLive— Individual portfolio page. Subscribes to PubSub"portfolio:{slug}"for real-time updates when holdings change.
Both pages use server-rendered HTML via LiveView — no JavaScript framework needed. Updates are pushed over WebSocket automatically.
| Pattern | Where | Purpose |
|---|---|---|
| GenServer | PortfolioWorker | Per-portfolio stateful process |
| DynamicSupervisor | PortfolioSupervisor | Start/stop workers at runtime |
| Registry | PortfolioRegistry | Name-based worker lookup |
| Phoenix PubSub | LiveView pages | Push updates to browser |
| Supervised connection | Nats.Connection | Auto-reconnecting NATS client |
The project includes a devcontainer configuration with NATS included. This is the easiest way to get the full event system running locally.
- Open the project in VS Code or any devcontainer-compatible editor
- Reopen in container (or
devcontainer up) - NATS is automatically available at
nats:4222inside the container - Forwarded ports:
4000(Phoenix),4222(NATS),8222(NATS monitoring)
The NATS monitoring UI is available at http://localhost:8222 to inspect connections, subjects, and JetStream streams.
Prerequisites: Erlang 27.3+ and Elixir 1.18+ (managed via asdf, see .tool-versions). Optionally, a NATS server on port 4222.
mix setup # Install deps, build assets
mix phx.server # Start dev server at localhost:4000
iex -S mix phx.server # Start with interactive Elixir shellmix test# Run all tests
mix test test/path/to/test.exs # Run specific file
mix test test/path/to/test.exs:10 # Run specific test by linemix format # Format code
mix format --check-formatted # Check formatting (CI)
mix compile --warnings-as-errors # Strict compilation (CI)
mix precommit # Run all checksDeployed via Kamal to the same Hetzner ARM64 VPS as the Rails app.
| Environment | URL | Branch | Deploy command |
|---|---|---|---|
| Production | pulse.quantic.es | main | kamal deploy |
| Beta | beta-pulse.quantic.es | beta | kamal deploy -d beta |
Auto-deploys on CI success for main and beta branches.
The deploy workflow needs these repository secrets (Settings > Secrets and variables > Actions):
| Secret | Description |
|---|---|
SSH_PRIVATE_KEY | Private key for SSH access to the VPS (same key as dividend-portfolio) |
BW_ACCOUNT | Your Bitwarden login email (kept out of git) |
BW_CLIENTID | Bitwarden personal API key — client_id (Account Settings → Security → Keys) |
BW_CLIENTSECRET | Bitwarden personal API key — client_secret |
BW_PASSWORD | Bitwarden master password — used to unlock the vault non-interactively in CI |
KAMAL_REGISTRY_PASSWORD, SECRET_KEY_BASE, and LOGO_SERVICE_API_KEY
live in a single Bitwarden Secure Note item (quantic-prod) holding the
values as custom fields, fetched at deploy time by .kamal/secrets via
kamal secrets fetch --adapter bitwarden. The deploy workflow installs
the bw CLI via npm, configures the EU server, logs in with
bw login --apikey, unlocks with bw unlock --raw --passwordenv BW_PASSWORD,
and exports the resulting BW_SESSION to $GITHUB_ENV — kamal then
inherits the session and skips its own login attempt.
For local Kamal commands, copy the sample files and fill them in (both gitignored; direnv loads .env):
cp env.sample .env # fill in BW_CLIENTID + BW_CLIENTSECRET + BW_PASSWORD
cp envrc.sample .envrc
direnv allow
The 1Password version is preserved verbatim at .kamal/secrets.1password.example
(and .kamal/secrets.beta.1password.example). To roll back:
cp .kamal/secrets.1password.example .kamal/secrets
cp .kamal/secrets.beta.1password.example .kamal/secrets.betaThen revert the BW install + env block in .github/workflows/deploy.yml.
1P vault was never touched — all secrets are still there.
Runtime config, set in config/deploy.yml (the secret values among these are fetched from Bitwarden):
| Variable | Description |
|---|---|
SECRET_KEY_BASE | Phoenix secret — fetched from Bitwarden |
LOGO_SERVICE_API_KEY | Auth key for the logo service — fetched from Bitwarden |
PHX_HOST | Hostname for URL generation |
PHX_SERVER | Set to true to start the HTTP server |
PORT | HTTP port (default: 4000) |
NATS_HOST | NATS server hostname |
NATS_PORT | NATS server port (default: 4222) |
NATS_ENV_PREFIX | Event subject prefix (prod, beta, dev) |