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>
This commit is contained in:
2026-02-23 22:28:29 +01:00
co-authored by Claude Opus 4.6
parent 02cc607e73
commit 13a53a26d0
5 changed files with 24 additions and 60 deletions
+3 -3
View File
@@ -36,9 +36,9 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo
- **Framework:** Kit (Integrant-based) - **Framework:** Kit (Integrant-based)
- **Server:** Undertow - **Server:** Undertow
- **Routing:** Reitit - **Routing:** Reitit
- **Templates:** Hiccup + vanilla JS WebSocket - **Templates:** Hiccup + HTMX (WebSocket extension)
- **Styling:** Tailwind CSS v4 - **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 - **No database** — all data comes from btcdata
## Source Layout ## Source Layout
@@ -58,6 +58,6 @@ src/clj/pmagnus/btcprice/
│ └── formats.clj # Content negotiation │ └── formats.clj # Content negotiation
└── routes/ └── routes/
├── api.clj # /api routes (JSON) ├── api.clj # /api routes (JSON)
├── ui.clj # UI routes (HTML + WebSocket) ├── ui.clj # UI routes (HTML + HTMX WebSocket)
└── utils.clj # Route utilities └── utils.clj # Route utilities
``` ```
+4 -4
View File
@@ -1,6 +1,6 @@
# btcprice # 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 ## Quick Start
@@ -21,12 +21,12 @@ docker compose up -d # btcprice on 4041, btcdata on 4101
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `PORT` | `4000` | HTTP server port | | `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 ## 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 ## Tech Stack
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4 Clojure · Kit · Undertow · Reitit · Hiccup · HTMX · Tailwind CSS v4
+1 -1
View File
@@ -8,4 +8,4 @@ services:
ports: ports:
- "4041:4040" - "4041:4040"
environment: environment:
BTCDATA_URL: "http://localhost:4101" BTCDATA_URL: "http://192.168.50.42:4101"
+3 -1
View File
@@ -17,7 +17,9 @@
[:meta {:name "apple-mobile-web-app-capable" :content "yes"}] [:meta {:name "apple-mobile-web-app-capable" :content "yes"}]
[:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}] [:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
[:title (or (:title opts#) "BTC Price")] [: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 [:body
[:div.mx-auto.max-w-lg.px-4.py-6 [:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))})) ~@content]]]))}))
+13 -51
View File
@@ -8,59 +8,21 @@
[reitit.ring.middleware.parameters :as parameters])) [reitit.ring.middleware.parameters :as parameters]))
(defn- home-page [{:keys [btcdata-url]} _req] (defn- home-page [{:keys [btcdata-url]} _req]
(page {:title "BTC Price"} (let [ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws")
[:header.text-center.mb-8 "/api/price/ws")]
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"] (page {:title "BTC Price"}
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]] [: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-url}
[:div#price-display [:div#price-display
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]] [:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
[:script ;; Safelist classes used in btcdata HTML fragments
(hiccup2.core/raw [:div {:class "hidden text-4xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2"
(str :aria-hidden "true"}])))
"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"))]))
(defn- ui-routes [opts] (defn- ui-routes [opts]
[["/" [["/"