Compare commits

..
10 Commits
Author SHA1 Message Date
magnusandClaude Opus 4.6 02cc607e73 Replace EventSource with WebSocket for price streaming
- Switch from SSE EventSource to WebSocket client connecting to
  btcdata's new /api/price/ws endpoint
- Add automatic reconnect on connection loss (3s delay)
- Update Dockerfile BTCDATA_URL to use btcdata's new Docker port 4101

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:32:21 +01:00
magnusandClaude Opus 4.6 ce343dfcf6 Add README, update docker-compose and CLAUDE.md
Docker runs on port 4041 alongside local dev on 4000.
Include btcdata docker-compose for unified startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:48:56 +01:00
magnusandClaude Opus 4.6 397d9f7b5c Split data layer into btcdata service
Remove DB, Binance WebSocket, and Kraken pollers from btcprice.
UI now uses vanilla EventSource connecting to btcdata for live
price updates. btcprice is now a pure UI service with no database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:07:48 +01:00
magnusandClaude Opus 4.6 c688846a26 Add Kraken daily OHLC poller with database storage
Polls Kraken API for 1-day BTC/USD candles aligned to 00:01 UTC,
mirroring the existing hourly poller pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:53:50 +01:00
magnusandClaude Opus 4.6 30aa52cdc7 Schedule Kraken OHLC polls at HH:01 UTC instead of fixed 5-min interval
Hourly candles only finalize on the hour, so polling every 5 minutes
was wasteful. Now the poller sleeps until 1 minute past each hour,
fetches immediately on first run when no data exists, and logs time
to next fetch on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:40:36 +01:00
magnusandClaude Opus 4.6 553dac3069 Add Kraken hourly OHLC poller with database storage
Fetches Bitcoin hourly candles from the Kraken public REST API
and upserts them into a kraken_hour table on a 5-minute poll interval,
following the existing Integrant component pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:32:55 +01:00
magnusandClaude Opus 4.6 2c576aff4e Fix Dockerfile base images and add docker-compose
Switch to available base images (temurin-21) since clojure:openjdk-25
and azul/zulu-openjdk-alpine:25 no longer exist. Add docker-compose.yml
for single-command app deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:55:34 +01:00
magnusandClaude Opus 4.6 201155c0b2 Update Dockerfile with Tailwind build stage and prod database
Add Node.js stage for CSS build, cache Clojure deps in separate layer,
add .dockerignore, and default JDBC_URL to btcprod database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:36:38 +01:00
magnusandClaude Opus 4.6 8169796eb9 Color price green/red based on direction, fix SSE middleware error
Show price in green when rising, red when falling. Use Tailwind
classes with a safelist comment so JIT includes them. Remove
middleware from SSE route to prevent "response already started" error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:32:40 +01:00
magnusandClaude Opus 4.6 08793c5099 Switch price updates from HTMX polling to SSE
Replace 2s polling with Server-Sent Events for instant price updates.
Write directly to Undertow exchange output stream to ensure proper
flushing, since the Ring adapter buffers InputStream bodies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:09:47 +01:00
18 changed files with 143 additions and 201 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
node_modules/
.env
.cpcache/
.nrepl-port
+19 -11
View File
@@ -1,15 +1,26 @@
# btcprice
Bitcoin price tracker - Clojure Kit web application.
Bitcoin price tracker UI — Clojure Kit web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket.
## Build & Development Commands
- `make run` — Start dev server on port 3000
- `make repl` — Start nREPL on port 7888
- `make run` — Start dev server on port 4000
- `make repl` — Start nREPL
- `make test` — Run tests
- `make tailwind` — Watch Tailwind CSS for changes
- `make uberjar` — Build production JAR
- `docker compose up -d` — Start PostgreSQL
- `docker compose up -d` — Start btcprice + btcdata containers
## Docker
Docker runs alongside local dev on different ports:
| Service | Local dev | Docker |
|----------|----------------|----------------|
| btcprice | localhost:4000 | localhost:4041 |
| btcdata | localhost:4100 | localhost:4101 |
btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`.
## REPL Commands
@@ -18,10 +29,6 @@ Bitcoin price tracker - Clojure Kit web application.
(go) ;; Start system
(reset) ;; Reload code & restart
(halt) ;; Stop system
(reset-db) ;; Drop & re-migrate database
(migrate) ;; Run pending migrations
(rollback) ;; Rollback last migration
(query-fn) ;; Get database query function
```
## Architecture
@@ -29,9 +36,10 @@ Bitcoin price tracker - Clojure Kit web application.
- **Framework:** Kit (Integrant-based)
- **Server:** Undertow
- **Routing:** Reitit
- **Templates:** Hiccup + HTMX
- **Templates:** Hiccup + vanilla JS WebSocket
- **Styling:** Tailwind CSS v4
- **Database:** PostgreSQL + conman + Migratus
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100, WebSocket at ws://localhost:4100)
- **No database** — all data comes from btcdata
## Source Layout
@@ -50,6 +58,6 @@ src/clj/pmagnus/btcprice/
│ └── formats.clj # Content negotiation
└── routes/
├── api.clj # /api routes (JSON)
├── ui.clj # UI routes (HTML)
├── ui.clj # UI routes (HTML + WebSocket)
└── utils.clj # Route utilities
```
+18 -4
View File
@@ -1,14 +1,28 @@
FROM clojure:openjdk-25 AS build
FROM node:22-alpine AS css
WORKDIR /build
COPY . .
RUN clj -Sforce -T:build all
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/css/ resources/css/
COPY src/ src/
COPY tailwind.config.js ./
RUN npm run css:build
FROM azul/zulu-openjdk-alpine:25
FROM clojure:temurin-21-tools-deps-alpine AS build
WORKDIR /build
COPY deps.edn build.clj ./
RUN clj -Sforce -P
COPY . .
COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css
RUN clj -T:build all
FROM eclipse-temurin:21-jre-alpine
COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar
EXPOSE 4040
ENV PORT=4040
ENV BTCDATA_URL=http://btcdata:4101
CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"]
+32
View File
@@ -0,0 +1,32 @@
# btcprice
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via Server-Sent Events.
## Quick Start
```bash
# Start btcdata first (provides price data)
cd ../btcdata && make run
# Local dev
make run # starts on port 4000
make tailwind # watch Tailwind CSS (separate terminal)
# Docker (starts both btcprice + btcdata)
docker compose up -d # btcprice on 4041, btcdata on 4101
```
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `4000` | HTTP server port |
| `BTCDATA_URL` | `http://localhost:4100` | btcdata service URL (injected into browser JS) |
## 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).
## Tech Stack
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4
+1 -8
View File
@@ -1,7 +1,5 @@
{:paths ["src/clj" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
org.clojure/data.json {:mvn/version "2.5.1"}
;; Kit
io.github.kit-clj/kit-core {:mvn/version "1.0.6"}
@@ -27,12 +25,7 @@
;; Serialization
metosin/muuntaja {:mvn/version "0.6.11"}
luminus-transit/luminus-transit {:mvn/version "0.1.6"}
;; Database
io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"}
io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"}
io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"}}
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
:aliases
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
+11
View File
@@ -0,0 +1,11 @@
include:
- path: ../btcdata/docker-compose.yml
services:
btcprice:
build: .
restart: unless-stopped
ports:
- "4041:4040"
environment:
BTCDATA_URL: "http://localhost:4101"
-25
View File
@@ -1,6 +1,5 @@
(ns user
(:require
[clojure.tools.logging :as log]
[integrant.core :as ig]
[integrant.repl :refer [go halt reset reset-all set-prep!]]
[integrant.repl.state :as state]
@@ -19,30 +18,6 @@
(-> (config/system-config {:profile :test})
(ig/expand)))))
(defn reset-db []
(let [sys (or @pmagnus.btcprice.core/system
integrant.repl.state/system)]
(when-let [mig (:db.sql/migrations sys)]
(migratus.core/reset mig)
(log/info "Database reset complete"))))
(defn rollback []
(let [sys (or @pmagnus.btcprice.core/system
integrant.repl.state/system)]
(when-let [mig (:db.sql/migrations sys)]
(migratus.core/rollback mig))))
(defn migrate []
(let [sys (or @pmagnus.btcprice.core/system
integrant.repl.state/system)]
(when-let [mig (:db.sql/migrations sys)]
(migratus.core/migrate mig))))
(defn query-fn []
(let [sys (or @pmagnus.btcprice.core/system
integrant.repl.state/system)]
(:db.sql/query-fn sys)))
(comment
(dev-prep!)
(go)
@@ -1 +0,0 @@
DROP TABLE binance_price;
@@ -1,5 +0,0 @@
CREATE TABLE binance_price (
id BIGSERIAL PRIMARY KEY,
price NUMERIC(18,8) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-1
View File
@@ -1 +0,0 @@
Migration files go here.
File diff suppressed because one or more lines are too long
-9
View File
@@ -1,9 +0,0 @@
-- queries for btcprice
-- :name insert-binance-price! :! :n
-- :doc Insert a Binance BTC price
INSERT INTO binance_price (price) VALUES (:price)
-- :name get-latest-binance-price :? :1
-- :doc Get the most recent Binance BTC price
SELECT price, recorded_at FROM binance_price ORDER BY id DESC LIMIT 1
+3 -21
View File
@@ -32,27 +32,9 @@
{:routes #ig/ref :router/routes
:env #ig/ref :system/env}
:db.sql/connection
{:jdbc-url #env JDBC_URL}
:db.sql/query-fn
{:conn #ig/ref :db.sql/connection
:options {}
:filename "queries.sql"}
:db.sql/migrations
{:store :database
:db {:datasource #ig/ref :db.sql/connection}
:migrate-on-init? true}
:reitit.routes/api
{:base-path "/api"
:query-fn #ig/ref :db.sql/query-fn}
{:base-path "/api"}
:reitit.routes/ui
{:base-path ""
:query-fn #ig/ref :db.sql/query-fn}
:ws/binance
{:query-fn #ig/ref :db.sql/query-fn
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"}}
{:base-path ""
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
+1 -6
View File
@@ -6,16 +6,11 @@
[pmagnus.btcprice.env :refer [defaults]]
;; Edges
[kit.edge.db.postgres]
[kit.edge.db.sql.conman]
[kit.edge.db.sql.migratus]
[kit.edge.server.undertow]
[pmagnus.btcprice.web.handler]
;; Routes
[pmagnus.btcprice.web.routes.api]
[pmagnus.btcprice.web.routes.ui]
;; WebSocket clients
[pmagnus.btcprice.ws.binance])
[pmagnus.btcprice.web.routes.ui])
(:gen-class))
(defonce system (atom nil))
+1 -2
View File
@@ -17,8 +17,7 @@
[: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"}]
[:script {:src "https://unpkg.com/htmx.org@2.0.8"}]]
[:link {:rel "stylesheet" :href "/css/output.css"}]]
[:body
[:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))}))
+1 -2
View File
@@ -8,8 +8,7 @@
[reitit.ring.coercion :as coercion]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]
[reitit.swagger :as swagger]
[reitit.swagger-ui :as swagger-ui]))
[reitit.swagger :as swagger]))
(defn- api-routes [_opts]
[["/swagger.json"
+50 -20
View File
@@ -1,40 +1,70 @@
(ns pmagnus.btcprice.web.routes.ui
(:require
[integrant.core :as ig]
[pmagnus.btcprice.web.htmx :refer [page fragment]]
[pmagnus.btcprice.web.htmx :refer [page]]
[pmagnus.btcprice.web.middleware.exception :as exception]
[pmagnus.btcprice.web.middleware.formats :as formats]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]))
(defn home-page [_req]
(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"]]
[:div#price-panel.space-y-4
{:hx-get "/price" :hx-trigger "every 2s" :hx-swap "innerHTML"}
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
[:p.text-center.text-gray-400.text-sm "Loading..."]]]))
(defn price-fragment [{:keys [query-fn]}]
(let [row (query-fn :get-latest-binance-price {})]
(fragment {}
[:div#price-panel
[:div#price-display
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
(if row
[:div.text-center
[:p.text-4xl.font-bold.text-gray-900
(str "$" (format "%,.2f" (double (:price row))))]
[:p.text-xs.text-gray-400.mt-2
(str "Updated " (:recorded_at row))]]
[:p.text-center.text-gray-400.text-sm "Waiting for data..."])])))
[: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"))]))
(defn- ui-routes [opts]
[["/"
{:get home-page}]
["/price"
{:get (fn [_req] (price-fragment opts))}]])
{:get (fn [req] (home-page opts req))}]])
(defn route-data [opts]
(merge
-85
View File
@@ -1,85 +0,0 @@
(ns pmagnus.btcprice.ws.binance
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log]
[integrant.core :as ig])
(:import
[java.net URI]
[java.net.http HttpClient WebSocket WebSocket$Listener]
[java.util.concurrent CompletableFuture CompletionStage]))
(defn- save-price! [query-fn price]
(try
(query-fn :insert-binance-price! {:price price})
(catch Exception e
(log/error e "Failed to save BTC price"))))
(defn- connect!
"Opens a WebSocket to Binance trade stream and returns the WebSocket instance.
`state` is an atom with keys :running?, :last-write, :buffer."
[uri query-fn state]
(let [client (HttpClient/newHttpClient)
listener (reify WebSocket$Listener
(onOpen [_ ws]
(log/info "Binance WebSocket connected")
(.request ws 1))
(onText [_ ws data last?]
(let [buf (:buffer @state)]
(.append buf data)
(when last?
(let [text (str buf)]
(.setLength buf 0)
(try
(let [msg (json/read-str text :key-fn keyword)
price (some-> (:p msg) bigdec)]
(when price
(let [now (System/currentTimeMillis)]
(when (> (- now (:last-write @state)) 5000)
(swap! state assoc :last-write now)
(log/info "BTC price:" (str price))
(save-price! query-fn price)))))
(catch Exception e
(log/error e "Failed to parse Binance message")))))
(let [^CompletionStage cf (CompletableFuture/completedFuture nil)]
(.request ws 1)
cf)))
(onClose [_ _ws code reason]
(log/warn "Binance WebSocket closed:" code reason)
(when (:running? @state)
(future
(Thread/sleep 3000)
(when (:running? @state)
(log/info "Reconnecting to Binance...")
(try
(let [ws (connect! uri query-fn state)]
(swap! state assoc :ws ws))
(catch Exception e
(log/error e "Binance reconnect failed")))))))
(onError [_ _ws error]
(log/error error "Binance WebSocket error")))]
(-> (.newWebSocketBuilder client)
(.buildAsync (URI. uri) listener)
(.join))))
(defmethod ig/init-key :ws/binance
[_ {:keys [query-fn uri]}]
(log/info "Starting Binance WebSocket listener:" uri)
(let [state (atom {:running? true
:last-write 0
:buffer (StringBuilder.)
:ws nil})
ws (connect! uri query-fn state)]
(swap! state assoc :ws ws)
state))
(defmethod ig/halt-key! :ws/binance
[_ state]
(log/info "Stopping Binance WebSocket listener")
(swap! state assoc :running? false)
(when-let [ws (:ws @state)]
(try
(.sendClose ws WebSocket/NORMAL_CLOSURE "shutting down")
(catch Exception _))))