diff --git a/CLAUDE.md b/CLAUDE.md
index 78cbf7f..8615c58 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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://192.168.50.42:4100)
- **No database** — all data comes from btcdata
## Source Layout
@@ -58,6 +58,6 @@ 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)
└── utils.clj # Route utilities
```
diff --git a/README.md b/README.md
index 75014e3..489e6c5 100644
--- a/README.md
+++ b/README.md
@@ -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 `
` 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
diff --git a/docker-compose.yml b/docker-compose.yml
index f94f6d9..8d22f5e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,4 +8,4 @@ services:
ports:
- "4041:4040"
environment:
- BTCDATA_URL: "http://localhost:4101"
+ BTCDATA_URL: "http://192.168.50.42:4101"
diff --git a/src/clj/pmagnus/btcprice/web/htmx.clj b/src/clj/pmagnus/btcprice/web/htmx.clj
index d5019c0..04f1554 100644
--- a/src/clj/pmagnus/btcprice/web/htmx.clj
+++ b/src/clj/pmagnus/btcprice/web/htmx.clj
@@ -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]]]))}))
diff --git a/src/clj/pmagnus/btcprice/web/routes/ui.clj b/src/clj/pmagnus/btcprice/web/routes/ui.clj
index 07ceaaa..cd09d27 100644
--- a/src/clj/pmagnus/btcprice/web/routes/ui.clj
+++ b/src/clj/pmagnus/btcprice/web/routes/ui.clj
@@ -8,59 +8,21 @@
[reitit.ring.middleware.parameters :as parameters]))
(defn- home-page [{:keys [btcdata-url]} _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"]]
+ (let [ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws")
+ "/api/price/ws")]
+ (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-display
- [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
- [:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
+ [:div#price-panel {:hx-ext "ws" :ws-connect ws-url}
+ [: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 = '
' +\n"
- " '
' +\n"
- " '
' + fmt + '
' +\n"
- " '
Updated ' + timeStr + '
' +\n"
- " '
';\n"
- " };\n"
- "\n"
- " ws.onclose = function() {\n"
- " display.innerHTML = '
' +\n"
- " '
Connection lost. Reconnecting...
';\n"
- " setTimeout(connect, 3000);\n"
- " };\n"
- "\n"
- " ws.onerror = function() {};\n"
- " }\n"
- "\n"
- " connect();\n"
- "});\n"))]))
+ ;; Safelist classes used in btcdata HTML fragments
+ [:div {:class "hidden text-4xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2"
+ :aria-hidden "true"}])))
(defn- ui-routes [opts]
[["/"