diff --git a/deps.edn b/deps.edn index 22ab3a2..6464196 100644 --- a/deps.edn +++ b/deps.edn @@ -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"}} diff --git a/src/clj/pmagnus/btcprice/web/routes/ui.clj b/src/clj/pmagnus/btcprice/web/routes/ui.clj index d87351f..eedf255 100644 --- a/src/clj/pmagnus/btcprice/web/routes/ui.clj +++ b/src/clj/pmagnus/btcprice/web/routes/ui.clj @@ -18,8 +18,13 @@ [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 [:p.text-center.text-gray-400.text-sm "Connecting..."]]]] + [: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 gap-4 mt-1 mt-3 text-sm text-gray-600" + [: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] diff --git a/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj b/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj index 29f29c6..8862804 100644 --- a/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj +++ b/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj @@ -1,11 +1,15 @@ (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] @@ -17,9 +21,9 @@ (try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _)))) (defn- connect-upstream - "Open a Java HttpClient WebSocket to btcdata and relay text frames to the - browser channel. Returns the upstream WebSocket." - [^String btcdata-ws-url ^WebSocketChannel browser-ch] + "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] @@ -27,8 +31,8 @@ (onText [_ ws data last?] (let [text (str data)] (try - (when (.isOpen browser-ch) - (WebSockets/sendTextBlocking text browser-ch)) + (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)))) @@ -45,25 +49,89 @@ (.buildAsync (URI. btcdata-ws-url) listener) (.join)))) -(defn- ws-proxy-handler [{:keys [btcdata-url]} _req] - (let [btcdata-ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws") - "/api/price/ws") - upstream-atom (atom nil)] +(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))) + (reset! upstream-atom (connect-upstream btcdata-ws-url channel xf))) :on-close-message (fn [_] (log/debug "Browser closed") (close-upstream-quietly @upstream-atom))}})) -(defn- ws-proxy-routes [opts] - [["/ws/price" - {:get (fn [req] (ws-proxy-handler opts req)) - :no-doc true - :middleware []}]]) +;; --- 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 "
" + "
" + "
" + "

$" (da-fmt "%,.2f" 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 "
" + "\u20AC" (da-fmt "%,.0f" p-eur) "" + "" (da-fmt "%,.0f" p-dkk) " kr" + "
" + "
" + "EUR/USD " (da-fmt "%.4f" eur) "" + "DKK/USD " (da-fmt "%.4f" dkk) "" + "
"))) + "

Updated " ts "

" + "
"))) + +;; --- Strike rendering --- + +(def ^:private strike-pairs ["BTC/USDT" "BTC/EUR" "USDT/EUR" "EUR/BTC"]) + +(defn- strike-json->html [text] + (let [rates (json/read-str text)] + (str "
" + "
" + "

Strike Ticker

" + "
" + (apply str + (for [pair strike-pairs + :let [amount (get rates pair)]] + (str "
" + "" pair "" + "" + (or amount "\u2014") + "
"))) + "
"))) + +;; --- 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)