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] [["/"