Compare commits

...
11 Commits
Author SHA1 Message Date
magnusandClaude Opus 4.6 ae676f2b6c Use quote-sats from btcdata instead of local EUR/BTC calculation
Sats value now comes from btcdata's Strike quote endpoint which
includes the actual fee/spread, matching the Strike app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:42:51 +01:00
magnusandClaude Opus 4.6 6b4c8034f1 Make sats EUR amount configurable, default 54.46
Configurable via SATS_EUR_AMOUNT env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:43:48 +01:00
magnusandClaude Opus 4.6 161737d4c7 Style Strike ticker: single column, currency symbols, Danish formatting, sats display
Show 54€ equivalent in sats with dark blue styling, light gray background,
and Danish number formatting for BTC/USDT and BTC/EUR values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:39:26 +01:00
magnusandClaude Opus 4.6 5990b8d735 Render price and strike HTML in btcprice from btcdata JSON WebSockets
Receive raw JSON from btcdata, transform to HTML in ws_proxy.clj.
Added data.json dependency, price and strike rendering, safelist updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:56 +01:00
magnusandClaude Opus 4.6 134ee27ca6 Rename shared network to btc-network
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:10:57 +01:00
magnusandClaude Opus 4.6 71918ee4fd Use shared Docker network to connect to btcdata independently
Remove btcdata compose include, use external shared network instead
so both projects can run as separate Docker stacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:51:47 +01:00
magnusandClaude Opus 4.6 a9b7874f36 Use idiomatic undertow/websocket maps instead of raw WebSocket API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:50:53 +01:00
magnusandClaude Opus 4.6 32a1509482 Proxy btcdata WebSocket through btcprice for remote access
Browser can't reach the LAN-only btcdata WebSocket when accessing
btcprice remotely (Tailscale/Cloudflare Tunnel). Add a /ws/price
proxy route that relays btcdata frames to the browser so only
btcprice needs to be reachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:43:05 +01:00
magnusandClaude Opus 4.6 6afaa228c3 Safelist text-2xl and mt-1 Tailwind classes for larger currency prices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:52:10 +01:00
magnusandClaude Opus 4.6 5c1a080831 Add EUR/DKK rate display support and run-docker Makefile target
Safelist Tailwind classes for currency rate HTML fragments from btcdata
WebSocket. Add make run-docker target that connects to Docker btcdata
on port 4101.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:28:17 +01:00
magnusandClaude Opus 4.6 13a53a26d0 Switch to HTMX WebSocket, use LAN IP for btcdata URL
- Replace vanilla JS WebSocket with HTMX ws extension (hx-ext="ws")
- Add HTMX + ws extension CDN scripts to page head
- Zero client-side JavaScript — HTMX handles connection, reconnect, and
  DOM swap of HTML fragments from btcdata
- Hidden div safelists Tailwind classes used in btcdata HTML fragments
- Update BTCDATA_URL to LAN IP (192.168.50.42) for cross-machine access
- Update CLAUDE.md and README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:28:29 +01:00
12 changed files with 212 additions and 63 deletions
+4 -3
View File
@@ -36,9 +36,9 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo
- **Framework:** Kit (Integrant-based)
- **Server:** Undertow
- **Routing:** Reitit
- **Templates:** Hiccup + vanilla JS WebSocket
- **Templates:** Hiccup + HTMX (WebSocket extension)
- **Styling:** Tailwind CSS v4
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100, WebSocket at ws://localhost:4100)
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100)
- **No database** — all data comes from btcdata
## Source Layout
@@ -58,6 +58,7 @@ src/clj/pmagnus/btcprice/
│ └── formats.clj # Content negotiation
└── routes/
├── api.clj # /api routes (JSON)
├── ui.clj # UI routes (HTML + WebSocket)
├── ui.clj # UI routes (HTML + HTMX WebSocket)
├── ws_proxy.clj # WebSocket proxy to btcdata
└── utils.clj # Route utilities
```
+4 -1
View File
@@ -1,7 +1,7 @@
include .env
export
.PHONY: clean run repl tailwind test uberjar
.PHONY: clean run run-docker repl tailwind test uberjar
clean:
rm -rf target
@@ -9,6 +9,9 @@ clean:
run:
clj -M:dev -e "(dev-prep!) (go)" -r
run-docker:
BTCDATA_URL=http://localhost:4101 clj -M:dev -e "(dev-prep!) (go)" -r
repl:
clj -M:dev:nrepl
+4 -4
View File
@@ -1,6 +1,6 @@
# btcprice
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via Server-Sent Events.
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via WebSocket using HTMX.
## Quick Start
@@ -21,12 +21,12 @@ docker compose up -d # btcprice on 4041, btcdata on 4101
| Variable | Default | Description |
|---|---|---|
| `PORT` | `4000` | HTTP server port |
| `BTCDATA_URL` | `http://localhost:4100` | btcdata service URL (injected into browser JS) |
| `BTCDATA_URL` | `http://192.168.50.42:4100` | btcdata service URL (browser connects via WebSocket) |
## How It Works
The server renders an HTML page that includes a vanilla JavaScript `EventSource` connecting directly to btcdata's SSE endpoint (`BTCDATA_URL/api/price/stream`). Price updates are rendered client-side with color-coded changes (green up, red down).
The server renders an HTML page with HTMX and the WebSocket extension (`hx-ext="ws"`). HTMX connects directly to btcdata's WebSocket endpoint (`BTCDATA_URL/api/price/ws`), which sends HTML fragments. HTMX swaps the received `<div id="price-display">` into the DOM automatically. Price changes are color-coded (green up, red down). Reconnection is handled by HTMX with exponential backoff.
## Tech Stack
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4
Clojure · Kit · Undertow · Reitit · Hiccup · HTMX · Tailwind CSS v4
+2 -1
View File
@@ -25,7 +25,8 @@
;; Serialization
metosin/muuntaja {:mvn/version "0.6.11"}
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
luminus-transit/luminus-transit {:mvn/version "0.1.6"}
org.clojure/data.json {:mvn/version "2.5.1"}}
:aliases
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
+7 -3
View File
@@ -1,11 +1,15 @@
include:
- path: ../btcdata/docker-compose.yml
networks:
btc-network:
name: btc-network
external: true
services:
btcprice:
build: .
restart: unless-stopped
networks:
- btc-network
ports:
- "4041:4040"
environment:
BTCDATA_URL: "http://localhost:4101"
BTCDATA_URL: "http://btcdata:${DOCKER_BTCDATA_PORT:-4102}"
File diff suppressed because one or more lines are too long
+3
View File
@@ -36,5 +36,8 @@
{:base-path "/api"}
:reitit.routes/ui
{:base-path ""}
:reitit.routes/ws-proxy
{:base-path ""
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
+2 -1
View File
@@ -10,7 +10,8 @@
[pmagnus.btcprice.web.handler]
;; Routes
[pmagnus.btcprice.web.routes.api]
[pmagnus.btcprice.web.routes.ui])
[pmagnus.btcprice.web.routes.ui]
[pmagnus.btcprice.web.routes.ws-proxy])
(:gen-class))
(defonce system (atom nil))
+3 -1
View File
@@ -17,7 +17,9 @@
[:meta {:name "apple-mobile-web-app-capable" :content "yes"}]
[:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
[:title (or (:title opts#) "BTC Price")]
[:link {:rel "stylesheet" :href "/css/output.css"}]]
[:link {:rel "stylesheet" :href "/css/output.css"}]
[:script {:src "https://unpkg.com/htmx.org@2/dist/htmx.min.js"}]
[:script {:src "https://unpkg.com/htmx-ext-ws@2/ws.js"}]]
[:body
[:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))}))
@@ -2,8 +2,18 @@
(:require
[ring.middleware.defaults :as defaults]))
(defn- wrap-nil-guard
"Discard responses with no :status — these are artifacts of ring-defaults
middleware wrapping a nil response (e.g., from a raw WebSocket upgrade).
Passes through :undertow/websocket responses for the adapter to handle."
[handler]
(fn [request]
(let [resp (handler request)]
(when (or (:status resp) (:undertow/websocket resp)) resp))))
(defn wrap-base [{:keys [site-defaults-config]}]
(fn [handler]
(cond-> handler
true (defaults/wrap-defaults
(or site-defaults-config defaults/site-defaults)))))
(-> handler
(defaults/wrap-defaults
(or site-defaults-config defaults/site-defaults))
(wrap-nil-guard))))
+10 -45
View File
@@ -7,60 +7,25 @@
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]))
(defn- home-page [{:keys [btcdata-url]} _req]
(defn- home-page [_opts _req]
(page {:title "BTC Price"}
[:header.text-center.mb-8
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"]
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]]
[:div#price-panel
[:div#price-panel {:hx-ext "ws" :ws-connect "/ws/price"}
[:div#price-display
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
[:script
(hiccup2.core/raw
(str
"document.addEventListener('DOMContentLoaded', function() {\n"
" var btcdataUrl = '" btcdata-url "';\n"
" var wsUrl = btcdataUrl.replace(/^http/, 'ws');\n"
" var display = document.getElementById('price-display');\n"
"\n"
" function connect() {\n"
" var ws = new WebSocket(wsUrl + '/api/price/ws');\n"
"\n"
" ws.onmessage = function(e) {\n"
" var d = JSON.parse(e.data);\n"
" if (d.ping) return;\n"
" var price = parseFloat(d.price);\n"
" var prevPrice = d.prev_price ? parseFloat(d.prev_price) : null;\n"
" var color = 'text-gray-900';\n"
" if (prevPrice !== null) {\n"
" if (price > prevPrice) color = 'text-green-600';\n"
" else if (price < prevPrice) color = 'text-red-600';\n"
" }\n"
" var fmt = '$' + price.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});\n"
" var ts = new Date(d.recorded_at);\n"
" var pad = function(n) { return n < 10 ? '0' + n : n; };\n"
" var timeStr = pad(ts.getDate()) + '-' + pad(ts.getMonth()+1) + ' ' + pad(ts.getHours()) + ':' + pad(ts.getMinutes()) + ':' + pad(ts.getSeconds());\n"
" display.innerHTML = '<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">' +\n"
" '<div class=\"text-center\">' +\n"
" '<p class=\"text-4xl font-bold ' + color + '\">' + fmt + '</p>' +\n"
" '<p class=\"text-xs text-gray-400 mt-2\">Updated ' + timeStr + '</p>' +\n"
" '</div></div>';\n"
" };\n"
"\n"
" ws.onclose = function() {\n"
" display.innerHTML = '<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">' +\n"
" '<p class=\"text-center text-red-400 text-sm\">Connection lost. Reconnecting...</p></div>';\n"
" setTimeout(connect, 3000);\n"
" };\n"
"\n"
" ws.onerror = function() {};\n"
" }\n"
"\n"
" connect();\n"
"});\n"))]))
[:div.mt-4 {:hx-ext "ws" :ws-connect "/ws/strike"}
[:div#strike-display
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
[:p.text-center.text-gray-400.text-sm "Loading Strike rates..."]]]]
;; Safelist classes used in btcdata HTML fragments
[:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center justify-between items-center gap-4 mt-1 mt-3 text-sm text-gray-600 text-gray-500 mb-3 py-1 grid grid-cols-2 gap-x-6 gap-y-1"
:aria-hidden "true"}]))
(defn- ui-routes [opts]
[["/"
@@ -0,0 +1,159 @@
(ns pmagnus.btcprice.web.routes.ws-proxy
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log]
[integrant.core :as ig])
(:import
[io.undertow.websockets.core WebSockets WebSocketChannel]
[java.net URI]
[java.net.http HttpClient WebSocket$Builder WebSocket$Listener]
[java.time Instant ZoneId]
[java.time.format DateTimeFormatter]
[java.util Locale]
[java.util.concurrent CompletableFuture]))
(defn- close-quietly [^WebSocketChannel ch]
(when (and ch (.isOpen ch))
(try (.close ch) (catch Exception _))))
(defn- close-upstream-quietly [^java.net.http.WebSocket ws]
(when ws
(try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _))))
(defn- connect-upstream
"Open a Java HttpClient WebSocket to btcdata, transform each text frame
with xf, and send the result to the browser channel."
[^String btcdata-ws-url ^WebSocketChannel browser-ch xf]
(let [client (HttpClient/newHttpClient)
listener (reify java.net.http.WebSocket$Listener
(onOpen [_ ws]
(.request ws 1))
(onText [_ ws data last?]
(let [text (str data)]
(try
(when (and (.isOpen browser-ch) (seq text))
(WebSockets/sendTextBlocking (xf text) browser-ch))
(catch Exception e
(log/debug e "Error forwarding to browser")
(close-upstream-quietly ws))))
(.request ws 1)
(CompletableFuture/completedFuture nil))
(onClose [_ _ws status-code _reason]
(log/debug "Upstream closed" status-code)
(close-quietly browser-ch))
(onError [_ _ws error]
(log/debug error "Upstream error")
(close-quietly browser-ch)))]
(-> (.newWebSocketBuilder client)
^WebSocket$Builder identity
(.buildAsync (URI. btcdata-ws-url) listener)
(.join))))
(defn- ws-handler [btcdata-ws-url xf _req]
(let [upstream-atom (atom nil)]
{:undertow/websocket
{:on-open
(fn [{:keys [^WebSocketChannel channel]}]
(log/info "Browser connected, proxying to" btcdata-ws-url)
(reset! upstream-atom (connect-upstream btcdata-ws-url channel xf)))
:on-close-message
(fn [_]
(log/debug "Browser closed")
(close-upstream-quietly @upstream-atom))}}))
;; --- Price rendering ---
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
(def ^:private da-locale (Locale. "da" "DK"))
(defn- da-fmt [fmt val]
(String/format da-locale fmt (into-array Object [val])))
(defn- price-json->html [text]
(let [{:strs [price prev_price recorded_at
price_eur price_dkk]} (json/read-str text)
p (double (bigdec price))
pp (when prev_price (double (bigdec prev_price)))
color (cond
(nil? pp) "text-gray-900"
(> p pp) "text-green-600"
(< p pp) "text-red-600"
:else "text-gray-900")
ts (.format (.atZone (Instant/parse recorded_at) (ZoneId/systemDefault)) ts-fmt)]
(str "<div id=\"price-display\">"
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
"<div class=\"text-center\">"
"<p class=\"text-4xl font-bold " color "\">$" (da-fmt "%,.2f" p) "</p>"
(when price_eur
(let [p-eur (double (bigdec price_eur))
p-dkk (double (bigdec price_dkk))
eur (/ p-eur p)
dkk (/ p-dkk p)]
(str "<div class=\"flex justify-center gap-4 mt-3\">"
"<span class=\"text-2xl font-bold text-gray-600\">\u20AC" (da-fmt "%,.0f" p-eur) "</span>"
"<span class=\"text-2xl font-bold text-gray-600\">" (da-fmt "%,.0f" p-dkk) " kr</span>"
"</div>"
"<div class=\"flex justify-center gap-4 mt-1\">"
"<span class=\"text-xs text-gray-400\">EUR/USD " (da-fmt "%.4f" eur) "</span>"
"<span class=\"text-xs text-gray-400\">DKK/USD " (da-fmt "%.4f" dkk) "</span>"
"</div>")))
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
"</div></div></div>")))
;; --- Strike rendering ---
(def ^:private strike-pairs
[["BTC/USDT" "$"]
["BTC/EUR" "\u20AC"]
["USDT/EUR" ""]
["EUR/BTC" ""]])
(defn- format-strike-amount [prefix amount]
(if amount
(let [v (double (bigdec amount))]
(case prefix
"$" (str prefix (da-fmt "%,.2f" v))
"\u20AC" (str prefix (da-fmt "%,.2f" v))
amount))
"\u2014"))
(defn- strike-json->html [text]
(let [rates (json/read-str text)
sats (get rates "quote-sats")]
(str "<div id=\"strike-display\">"
"<div class=\"rounded-2xl shadow-sm border border-gray-200 p-6\" style=\"background:#f3f4f6;\">"
(if sats
(str "<p class=\"text-center text-2xl font-bold mb-3\" style=\"color:#1e3a5f;\">"
(da-fmt "%,d" sats) " <span style=\"color:#1e3a5f;\">sats</span></p>")
"")
"<div style=\"display:grid;grid-template-columns:1fr;row-gap:0.25rem;\">"
(apply str
(for [[pair prefix] strike-pairs
:let [amount (get rates pair)]]
(str "<div style=\"display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;\">"
"<span class=\"text-sm text-gray-500\">" pair "</span>"
"<span class=\"text-sm font-bold text-gray-900\">"
(format-strike-amount prefix amount)
"</span></div>")))
"</div></div></div>")))
;; --- Routes ---
(defn- ws-proxy-routes [{:keys [btcdata-url]}]
(let [ws-base (str (.replaceFirst ^String btcdata-url "^http" "ws"))]
[["/ws/price"
{:get (fn [req] (ws-handler (str ws-base "/api/price/ws") price-json->html req))
:no-doc true
:middleware []}]
["/ws/strike"
{:get (fn [req] (ws-handler (str ws-base "/api/strike/ws") strike-json->html req))
:no-doc true
:middleware []}]]))
(derive :reitit.routes/ws-proxy :reitit/routes)
(defmethod ig/init-key :reitit.routes/ws-proxy
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
[base-path {} (ws-proxy-routes opts)])