diff --git a/.gitignore b/.gitignore index e4ffb68..9c3b474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,6 @@ -/target -/classes -/checkouts -profiles.clj -pom.xml -pom.xml.asc -*.jar -*.class -/.lein-* -/.nrepl-port -/.cpcache -/.clj-kondo +__pycache__/ +*.pyc +.venv/ /node_modules /log .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index f1ee68b..66e9502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,12 @@ # btcprice -Bitcoin price tracker UI — Clojure Kit web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket. +Bitcoin price tracker UI — Python FastAPI 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 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 btcprice + btcdata containers +- `make test` — Run tests ## Docker @@ -22,21 +19,11 @@ Docker runs alongside local dev on different ports: btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`. -## REPL Commands - -```clojure -(dev-prep!) ;; Prepare dev system -(go) ;; Start system -(reset) ;; Reload code & restart -(halt) ;; Stop system -``` - ## Architecture -- **Framework:** Kit (Integrant-based) -- **Server:** Undertow -- **Routing:** Reitit -- **Templates:** Hiccup + HTMX (WebSocket extension) +- **Framework:** FastAPI +- **Server:** Uvicorn +- **Templates:** Inline HTML strings + HTMX (WebSocket extension) - **Styling:** Tailwind CSS v4 - **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100) - **No database** — all data comes from btcdata @@ -44,21 +31,6 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo ## Source Layout ``` -src/clj/pmagnus/btcprice/ -├── core.clj # App entry point -├── config.clj # System config loader -└── web/ - ├── handler.clj # Ring handler + routes - ├── htmx.clj # page/fragment macros - ├── controllers/ - │ └── health.clj # Health check - ├── middleware/ - │ ├── core.clj # Base middleware - │ ├── exception.clj # Exception handling - │ └── formats.clj # Content negotiation - └── routes/ - ├── api.clj # /api routes (JSON) - ├── ui.clj # UI routes (HTML + HTMX WebSocket) - ├── ws_proxy.clj # WebSocket proxy to btcdata - └── utils.clj # Route utilities +app/ +└── main.py # FastAPI app: routes, WebSocket proxy, HTML rendering ``` diff --git a/Dockerfile b/Dockerfile index 36268d4..39e0f28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,25 +4,22 @@ WORKDIR /build COPY package.json package-lock.json ./ RUN npm ci COPY resources/css/ resources/css/ -COPY src/ src/ +COPY app/ app/ COPY tailwind.config.js ./ RUN npm run css:build -FROM clojure:temurin-21-tools-deps-alpine AS build +FROM python:3.13-slim -WORKDIR /build -COPY deps.edn build.clj ./ -RUN clj -Sforce -P -COPY . . +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ app/ +COPY resources/public/ resources/public/ 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"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4040"] diff --git a/Makefile b/Makefile index c2c7b20..e7ac9e2 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,19 @@ include .env export -.PHONY: clean run run-docker repl tailwind test uberjar +.PHONY: clean run run-docker tailwind test clean: - rm -rf target + rm -rf __pycache__ app/__pycache__ run: - clj -M:dev -e "(dev-prep!) (go)" -r + .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload -run-docker: - BTCDATA_URL=http://localhost:4101 clj -M:dev -e "(dev-prep!) (go)" -r - -repl: - clj -M:dev:nrepl +run-local: + BTCDATA_URL=$(BTCDATA_LOCAL_URL) .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload tailwind: npm run css:watch test: - clj -M:test - -uberjar: - clj -T:build all + .venv/bin/python -m pytest tests/ diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e19b9bf --- /dev/null +++ b/app/main.py @@ -0,0 +1,246 @@ +import json +import logging +import os +from datetime import datetime + +import websockets +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("btcprice") + +BTCDATA_URL = os.environ.get("BTCDATA_URL", "http://localhost:4100") + +app = FastAPI() + +app.mount("/css", StaticFiles(directory="resources/public/css"), name="css") + + +@app.get("/favicon.ico", include_in_schema=False) +async def favicon(): + return FileResponse("resources/public/favicon.svg", media_type="image/svg+xml") + + +# --- HTML page --- + +HOME_HTML = """\ + + + + + + + + +BTC Price + + + + + +
+
+

BTC Price

+
+
+
+
+

Connecting...

+
+
+
+
+
+
+

Loading Strike rates...

+
+
+
+ +
+ +""" + + +@app.get("/", response_class=HTMLResponse) +async def home(): + return HOME_HTML + + +# --- Health endpoint --- + + +@app.get("/api/health") +async def health(): + return {"status": "up"} + + +# --- Formatting helpers (Danish locale) --- + + +def da_fmt(fmt: str, val: float) -> str: + """Format a number using Danish locale conventions.""" + if fmt == ",.2f": + # e.g. 12.345,67 + int_part = int(val) + frac = abs(val - int_part) + int_str = f"{int_part:,}".replace(",", ".") + return f"{int_str},{frac:.2f}"[:-1].split(",")[0] + "," + f"{frac:.2f}"[2:] + if fmt == ",.0f": + return f"{int(round(val)):,}".replace(",", ".") + if fmt == ".4f": + return f"{val:.4f}".replace(".", ",") + if fmt == ",d": + return f"{int(val):,}".replace(",", ".") + return str(val) + + +# --- Price transform --- + + +def price_json_to_html(text: str) -> str: + data = json.loads(text) + p = float(data["price"]) + pp = float(data["prev_price"]) if data.get("prev_price") is not None else None + + if pp is None: + color = "text-gray-900" + elif p > pp: + color = "text-green-600" + elif p < pp: + color = "text-red-600" + else: + color = "text-gray-900" + + recorded_at = data.get("recorded_at", "") + if recorded_at: + dt = datetime.fromisoformat(recorded_at.replace("Z", "+00:00")) + local_dt = dt.astimezone() + ts = local_dt.strftime("%d-%m %H:%M:%S") + else: + ts = "" + + html = ( + '
' + '
' + '
' + f'

${da_fmt(",.2f", p)}

' + ) + + if data.get("price_eur") is not None: + p_eur = float(data["price_eur"]) + p_dkk = float(data["price_dkk"]) + eur_rate = p_eur / p + dkk_rate = p_dkk / p + + html += ( + '
' + f'\u20ac{da_fmt(",.0f", p_eur)}' + f'{da_fmt(",.0f", p_dkk)} kr' + "
" + '
' + f'EUR/USD {da_fmt(".4f", eur_rate)}' + f'DKK/USD {da_fmt(".4f", dkk_rate)}' + "
" + ) + + html += f'

Updated {ts}

' + html += "
" + return html + + +# --- Strike transform --- + +STRIKE_PAIRS = [ + ("BTC/USDT", "$"), + ("BTC/EUR", "\u20ac"), + ("USDT/EUR", ""), + ("EUR/BTC", ""), +] + + +def format_strike_amount(prefix: str, amount) -> str: + if amount is None: + return "\u2014" + v = float(amount) + if prefix in ("$", "\u20ac"): + return f'{prefix}{da_fmt(",.2f", v)}' + return str(amount) + + +def strike_json_to_html(text: str) -> str: + rates = json.loads(text) + sats = rates.get("quote-sats") + + html = ( + '
' + '
' + ) + + if sats is not None: + html += ( + '

' + f'{da_fmt(",d", sats)} sats

' + ) + + html += '
' + + for pair, prefix in STRIKE_PAIRS: + amount = rates.get(pair) + html += ( + '
' + f'{pair}' + f'{format_strike_amount(prefix, amount)}' + "
" + ) + + html += "
" + return html + + +# --- WebSocket proxy --- + + +def _btcdata_ws_url(path: str) -> str: + return BTCDATA_URL.replace("http", "ws", 1) + path + + +async def _ws_proxy(browser_ws: WebSocket, upstream_path: str, transform): + await browser_ws.accept() + upstream_url = _btcdata_ws_url(upstream_path) + logger.info("Browser connected, proxying to %s", upstream_url) + + try: + async with websockets.connect(upstream_url, ping_interval=None) as upstream: + async for message in upstream: + if not message: + continue + try: + html = transform(message) + await browser_ws.send_text(html) + except WebSocketDisconnect: + break + except Exception: + logger.debug("Error forwarding to browser", exc_info=True) + break + except WebSocketDisconnect: + pass + except Exception: + logger.debug("Upstream connection error", exc_info=True) + finally: + try: + await browser_ws.close() + except Exception: + pass + + +@app.websocket("/ws/price") +async def ws_price(websocket: WebSocket): + await _ws_proxy(websocket, "/api/price/ws", price_json_to_html) + + +@app.websocket("/ws/strike") +async def ws_strike(websocket: WebSocket): + await _ws_proxy(websocket, "/api/strike/ws", strike_json_to_html) diff --git a/bb.edn b/bb.edn deleted file mode 100644 index 14dcd2e..0000000 --- a/bb.edn +++ /dev/null @@ -1,13 +0,0 @@ -{:tasks - {:run {:doc "Start dev server" - :task (shell "clj -M:dev")} - :nrepl {:doc "Start nREPL" - :task (shell "clj -M:dev:nrepl")} - :cider {:doc "Start CIDER" - :task (shell "clj -M:dev:cider")} - :test {:doc "Run tests" - :task (shell "clj -M:test")} - :uberjar {:doc "Build uberjar" - :task (shell "clj -T:build all")} - :format {:doc "Format code" - :task (shell "clj -M:dev -m cljstyle.main fix src test")}}} diff --git a/build.clj b/build.clj deleted file mode 100644 index a38ce0a..0000000 --- a/build.clj +++ /dev/null @@ -1,28 +0,0 @@ -(ns build - (:require [clojure.tools.build.api :as b])) - -(def lib 'pmagnus/btcprice) -(def version "0.0.1-SNAPSHOT") -(def target-dir "target") -(def class-dir (str target-dir "/classes")) - -(def basis (delay (b/create-basis {:project "deps.edn"}))) -(def uber-file (str target-dir "/btcprice-standalone.jar")) - -(defn clean [_] - (b/delete {:path target-dir})) - -(defn uber [_] - (clean nil) - (b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"] - :target-dir class-dir}) - (b/compile-clj {:basis @basis - :ns-compile '[pmagnus.btcprice.core] - :class-dir class-dir}) - (b/uber {:class-dir class-dir - :uber-file uber-file - :basis @basis - :main 'pmagnus.btcprice.core})) - -(defn all [_] - (uber nil)) diff --git a/deps.edn b/deps.edn deleted file mode 100644 index 6464196..0000000 --- a/deps.edn +++ /dev/null @@ -1,55 +0,0 @@ -{:paths ["src/clj" "resources"] - :deps {org.clojure/clojure {:mvn/version "1.12.3"} - ;; Kit - io.github.kit-clj/kit-core {:mvn/version "1.0.6"} - - ;; Logging - ch.qos.logback/logback-classic {:mvn/version "1.5.20"} - - ;; HTTP Server - io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"} - - ;; Routing - metosin/reitit {:mvn/version "0.9.2"} - metosin/reitit-ring {:mvn/version "0.9.2"} - metosin/reitit-middleware {:mvn/version "0.9.2"} - metosin/reitit-swagger {:mvn/version "0.9.2"} - metosin/reitit-swagger-ui {:mvn/version "0.9.2"} - metosin/reitit-malli {:mvn/version "0.9.2"} - - ;; Web - ring/ring-core {:mvn/version "1.15.3"} - ring/ring-defaults {:mvn/version "0.7.0"} - metosin/ring-http-response {:mvn/version "0.9.5"} - hiccup/hiccup {:mvn/version "2.0.0"} - - ;; Serialization - metosin/muuntaja {:mvn/version "0.6.11"} - 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"}} - :ns-default build} - - :dev {:extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"] - :extra-deps {integrant/repl {:mvn/version "0.5.0"} - criterium/criterium {:mvn/version "0.4.6"} - expound/expound {:mvn/version "0.9.0"} - com.lambdaisland/classpath {:mvn/version "0.4.44"} - ring/ring-devel {:mvn/version "1.15.3"}}} - - :test {:extra-paths ["env/test/resources"] - :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"} - peridot/peridot {:mvn/version "0.5.4"} - clj-commons/byte-streams {:mvn/version "0.3.4"}} - :main-opts ["-m" "cognitect.test-runner"] - :exec-fn cognitect.test-runner.api/test} - - :nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}} - :main-opts ["-m" "nrepl.cmdline" - "--middleware" "[integrant.repl/middleware]"]} - - :cider {:extra-deps {cider/cider-nrepl {:mvn/version "0.55.7"}} - :main-opts ["-m" "nrepl.cmdline" - "--middleware" "[cider.nrepl/cider-middleware]"]}}} diff --git a/env/dev/clj/pmagnus/btcprice/dev_middleware.clj b/env/dev/clj/pmagnus/btcprice/dev_middleware.clj deleted file mode 100644 index 95c2c32..0000000 --- a/env/dev/clj/pmagnus/btcprice/dev_middleware.clj +++ /dev/null @@ -1,4 +0,0 @@ -(ns pmagnus.btcprice.dev-middleware) - -(defn wrap-dev [handler _opts] - handler) diff --git a/env/dev/clj/pmagnus/btcprice/env.clj b/env/dev/clj/pmagnus/btcprice/env.clj deleted file mode 100644 index 6f3b7e0..0000000 --- a/env/dev/clj/pmagnus/btcprice/env.clj +++ /dev/null @@ -1,11 +0,0 @@ -(ns pmagnus.btcprice.env - (:require - [clojure.tools.logging :as log] - [pmagnus.btcprice.dev-middleware :refer [wrap-dev]])) - -(def defaults - {:init (fn [] (log/info "\n-=[btcprice starting...dev/test profile]=-")) - :start (fn [] (log/info "\n-=[btcprice started...dev/test profile]=-")) - :stop (fn [] (log/info "\n-=[btcprice has shut down]=-")) - :middleware wrap-dev - :opts {:profile :dev}}) diff --git a/env/dev/clj/user.clj b/env/dev/clj/user.clj deleted file mode 100644 index 4e8531c..0000000 --- a/env/dev/clj/user.clj +++ /dev/null @@ -1,25 +0,0 @@ -(ns user - (:require - [integrant.core :as ig] - [integrant.repl :refer [go halt reset reset-all set-prep!]] - [integrant.repl.state :as state] - [pmagnus.btcprice.config :as config] - [pmagnus.btcprice.core])) - -(defn dev-prep! [] - (set-prep! - (fn [] - (-> (config/system-config {:profile :dev}) - (ig/expand))))) - -(defn test-prep! [] - (set-prep! - (fn [] - (-> (config/system-config {:profile :test}) - (ig/expand))))) - -(comment - (dev-prep!) - (go) - (reset) - (halt)) diff --git a/env/dev/resources/logback.xml b/env/dev/resources/logback.xml deleted file mode 100644 index fbf1736..0000000 --- a/env/dev/resources/logback.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - diff --git a/env/prod/clj/pmagnus/btcprice/env.clj b/env/prod/clj/pmagnus/btcprice/env.clj deleted file mode 100644 index 11bb516..0000000 --- a/env/prod/clj/pmagnus/btcprice/env.clj +++ /dev/null @@ -1,10 +0,0 @@ -(ns pmagnus.btcprice.env - (:require - [clojure.tools.logging :as log])) - -(def defaults - {:init (fn [] (log/info "\n-=[btcprice starting]=-")) - :start (fn [] (log/info "\n-=[btcprice started successfully]=-")) - :stop (fn [] (log/info "\n-=[btcprice has shut down successfully]=-")) - :middleware (fn [handler _] handler) - :opts {:profile :prod}}) diff --git a/env/prod/resources/logback.xml b/env/prod/resources/logback.xml deleted file mode 100644 index 87d4bd2..0000000 --- a/env/prod/resources/logback.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - diff --git a/env/test/resources/logback.xml b/env/test/resources/logback.xml deleted file mode 100644 index fb48b5e..0000000 --- a/env/test/resources/logback.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - diff --git a/kit.edn b/kit.edn deleted file mode 100644 index cd37390..0000000 --- a/kit.edn +++ /dev/null @@ -1,8 +0,0 @@ -{:full-name "pmagnus/btcprice" - :ns-name "pmagnus.btcprice" - :sanitized "pmagnus/btcprice" - :name "btcprice" - :modules {:root "modules" - :repositories [{:url "https://github.com/kit-clj/modules.git" - :tag "master" - :name "kit-modules"}]}} diff --git a/modules/install-log.edn b/modules/install-log.edn deleted file mode 100644 index 0967ef4..0000000 --- a/modules/install-log.edn +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..097e302 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +websockets>=14.0 diff --git a/resources/css/input.css b/resources/css/input.css index 1009c3e..eaffd39 100644 --- a/resources/css/input.css +++ b/resources/css/input.css @@ -1,2 +1,2 @@ @import "tailwindcss"; -@source "../../src/**/*.clj"; +@source "../../app/**/*.py"; diff --git a/resources/public/favicon.svg b/resources/public/favicon.svg new file mode 100644 index 0000000..f58ef65 --- /dev/null +++ b/resources/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/resources/system.edn b/resources/system.edn deleted file mode 100644 index be1e749..0000000 --- a/resources/system.edn +++ /dev/null @@ -1,43 +0,0 @@ -{:system/env #profile {:dev :dev :test :test :prod :prod} - - :server/http - {:port #long #or [#env PORT 4000] - :host #or [#env HTTP_HOST "0.0.0.0"] - :handler #ig/ref :handler/ring} - - :handler/ring - {:router #ig/ref :router/core - :api-path "/api" - :site-defaults-config - {:params {:keywordize true - :multipart true - :nested true} - :cookies true - :session {:flash true - :cookie-attrs {:http-only true - :same-site :strict}} - :security {:anti-forgery false - :xss-protection {:enable? true :mode :block} - :frame-options :sameorigin} - :static {:resources "public"} - :responses {:not-modified-responses true - :absolute-redirects true - :content-types true - :default-charset "utf-8"}}} - - :router/routes - {:routes #ig/refset :reitit/routes} - - :router/core - {:routes #ig/ref :router/routes - :env #ig/ref :system/env} - - :reitit.routes/api - {:base-path "/api"} - - :reitit.routes/ui - {:base-path ""} - - :reitit.routes/ws-proxy - {:base-path "" - :btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}} diff --git a/src/clj/pmagnus/btcprice/config.clj b/src/clj/pmagnus/btcprice/config.clj deleted file mode 100644 index dd29a05..0000000 --- a/src/clj/pmagnus/btcprice/config.clj +++ /dev/null @@ -1,8 +0,0 @@ -(ns pmagnus.btcprice.config - (:require - [kit.config :as config])) - -(def ^:const system-filename "system.edn") - -(defn system-config [options] - (config/read-config system-filename options)) diff --git a/src/clj/pmagnus/btcprice/core.clj b/src/clj/pmagnus/btcprice/core.clj deleted file mode 100644 index 541483f..0000000 --- a/src/clj/pmagnus/btcprice/core.clj +++ /dev/null @@ -1,38 +0,0 @@ -(ns pmagnus.btcprice.core - (:require - [clojure.tools.logging :as log] - [integrant.core :as ig] - [pmagnus.btcprice.config :as config] - [pmagnus.btcprice.env :refer [defaults]] - - ;; Edges - [kit.edge.server.undertow] - [pmagnus.btcprice.web.handler] - ;; Routes - [pmagnus.btcprice.web.routes.api] - [pmagnus.btcprice.web.routes.ui] - [pmagnus.btcprice.web.routes.ws-proxy]) - (:gen-class)) - -(defonce system (atom nil)) - -(defn stop-app [] - ((or (:stop defaults) (fn []))) - (some-> (deref system) (ig/halt!)) - (shutdown-agents)) - -(defn start-app [& [params]] - ((or (:init defaults) (fn []))) - (->> (config/system-config (or params {})) - (ig/expand) - (ig/init) - (reset! system)) - ((or (:start defaults) (fn [])))) - -(defn -main [& _] - (Thread/setDefaultUncaughtExceptionHandler - (reify Thread$UncaughtExceptionHandler - (uncaughtException [_ _thread ex] - (log/error ex "Uncaught exception")))) - (start-app) - (.addShutdownHook (Runtime/getRuntime) (Thread. stop-app))) diff --git a/src/clj/pmagnus/btcprice/web/controllers/health.clj b/src/clj/pmagnus/btcprice/web/controllers/health.clj deleted file mode 100644 index f612714..0000000 --- a/src/clj/pmagnus/btcprice/web/controllers/health.clj +++ /dev/null @@ -1,11 +0,0 @@ -(ns pmagnus.btcprice.web.controllers.health - (:require - [ring.util.http-response :as response]) - (:import - [java.lang.management ManagementFactory])) - -(defn healthcheck! [_req] - (response/ok - {:time (str (java.time.Instant/now)) - :up-time (.. ManagementFactory getRuntimeMXBean getUptime) - :app {:status "up"}})) diff --git a/src/clj/pmagnus/btcprice/web/handler.clj b/src/clj/pmagnus/btcprice/web/handler.clj deleted file mode 100644 index b010ec3..0000000 --- a/src/clj/pmagnus/btcprice/web/handler.clj +++ /dev/null @@ -1,31 +0,0 @@ -(ns pmagnus.btcprice.web.handler - (:require - [integrant.core :as ig] - [reitit.ring :as ring] - [pmagnus.btcprice.web.middleware.core :as middleware])) - -(defmethod ig/init-key :handler/ring - [_ {:keys [router api-path] :as opts}] - (ring/ring-handler - (router) - (ring/routes - (ring/create-resource-handler {:path "/"}) - (when (some? api-path) - (reitit.ring/create-default-handler)) - (ring/create-default-handler - {:not-found (constantly {:status 404 :body "Page not found"}) - :method-not-allowed (constantly {:status 405 :body "Not allowed"}) - :not-acceptable (constantly {:status 406 :body "Not acceptable"})})) - {:middleware [(middleware/wrap-base opts)]})) - -(defmethod ig/init-key :router/routes - [_ {:keys [routes]}] - (mapv (fn [route] - (if (fn? route) (route) route)) - routes)) - -(defmethod ig/init-key :router/core - [_ {:keys [routes env]}] - (if (= env :dev) - (fn [] (ring/router routes)) - (constantly (ring/router routes)))) diff --git a/src/clj/pmagnus/btcprice/web/htmx.clj b/src/clj/pmagnus/btcprice/web/htmx.clj deleted file mode 100644 index 04f1554..0000000 --- a/src/clj/pmagnus/btcprice/web/htmx.clj +++ /dev/null @@ -1,32 +0,0 @@ -(ns pmagnus.btcprice.web.htmx - (:require - [hiccup2.core :as h])) - -(defmacro page - [opts & content] - `(let [opts# ~opts] - {:status 200 - :headers {"Content-Type" "text/html; charset=utf-8"} - :body (str - (h/html - (hiccup2.core/raw "") - [:html {:lang (or (:lang opts#) "en")} - [:head - [:meta {:charset "utf-8"}] - [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] - [: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/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]]]))})) - -(defmacro fragment - [opts & content] - `(let [_opts# ~opts] - {:status 200 - :headers {"Content-Type" "text/html; charset=utf-8"} - :body (str (h/html ~@content))})) diff --git a/src/clj/pmagnus/btcprice/web/middleware/core.clj b/src/clj/pmagnus/btcprice/web/middleware/core.clj deleted file mode 100644 index f2578a6..0000000 --- a/src/clj/pmagnus/btcprice/web/middleware/core.clj +++ /dev/null @@ -1,19 +0,0 @@ -(ns pmagnus.btcprice.web.middleware.core - (:require - [ring.middleware.defaults :as defaults])) - -(defn- wrap-nil-guard - "Discard responses with no :status — these are artifacts of ring-defaults - middleware wrapping a nil response (e.g., from a raw WebSocket upgrade). - Passes through :undertow/websocket responses for the adapter to handle." - [handler] - (fn [request] - (let [resp (handler request)] - (when (or (:status resp) (:undertow/websocket resp)) resp)))) - -(defn wrap-base [{:keys [site-defaults-config]}] - (fn [handler] - (-> handler - (defaults/wrap-defaults - (or site-defaults-config defaults/site-defaults)) - (wrap-nil-guard)))) diff --git a/src/clj/pmagnus/btcprice/web/middleware/exception.clj b/src/clj/pmagnus/btcprice/web/middleware/exception.clj deleted file mode 100644 index b266103..0000000 --- a/src/clj/pmagnus/btcprice/web/middleware/exception.clj +++ /dev/null @@ -1,21 +0,0 @@ -(ns pmagnus.btcprice.web.middleware.exception - (:require - [clojure.tools.logging :as log] - [reitit.ring.middleware.exception :as exception])) - -(defn- handler [message exception request] - (let [uri (:uri request)] - (log/error exception (str message " at " uri)) - {:status 500 - :body {:message message - :exception (.getClass exception) - :data (ex-data exception) - :uri uri}})) - -(def exception-middleware - (exception/create-exception-middleware - (merge - exception/default-handlers - {::exception/default (partial handler "Internal error") - ::exception/wrap (fn [handler e request] - (handler e request))}))) diff --git a/src/clj/pmagnus/btcprice/web/middleware/formats.clj b/src/clj/pmagnus/btcprice/web/middleware/formats.clj deleted file mode 100644 index 4fc5f58..0000000 --- a/src/clj/pmagnus/btcprice/web/middleware/formats.clj +++ /dev/null @@ -1,12 +0,0 @@ -(ns pmagnus.btcprice.web.middleware.formats - (:require - [luminus-transit.time :as time] - [muuntaja.core :as m])) - -(def instance - (m/create - (-> m/default-options - (update-in [:formats "application/transit+json" :decoder-opts] - (partial merge time/time-deserialization-handlers)) - (update-in [:formats "application/transit+json" :encoder-opts] - (partial merge time/time-serialization-handlers))))) diff --git a/src/clj/pmagnus/btcprice/web/routes/api.clj b/src/clj/pmagnus/btcprice/web/routes/api.clj deleted file mode 100644 index dbb8bc3..0000000 --- a/src/clj/pmagnus/btcprice/web/routes/api.clj +++ /dev/null @@ -1,41 +0,0 @@ -(ns pmagnus.btcprice.web.routes.api - (:require - [integrant.core :as ig] - [pmagnus.btcprice.web.controllers.health :as health] - [pmagnus.btcprice.web.middleware.exception :as exception] - [pmagnus.btcprice.web.middleware.formats :as formats] - [reitit.coercion.malli :as malli] - [reitit.ring.coercion :as coercion] - [reitit.ring.middleware.muuntaja :as muuntaja] - [reitit.ring.middleware.parameters :as parameters] - [reitit.swagger :as swagger])) - -(defn- api-routes [_opts] - [["/swagger.json" - {:get {:no-doc true - :swagger {:info {:title "btcprice API"}} - :handler (swagger/create-swagger-handler)}}] - ["/health" - {:get health/healthcheck!}]]) - -(defn route-data [opts] - (merge - opts - {:coercion malli/coercion - :muuntaja formats/instance - :swagger {:id ::api} - :middleware [parameters/parameters-middleware - muuntaja/format-negotiate-middleware - muuntaja/format-response-middleware - exception/exception-middleware - muuntaja/format-request-middleware - coercion/coerce-request-middleware - coercion/coerce-response-middleware]})) - -(derive :reitit.routes/api :reitit/routes) - -(defmethod ig/init-key :reitit.routes/api - [_ {:keys [base-path] - :or {base-path ""} - :as opts}] - [base-path (route-data opts) (api-routes opts)]) diff --git a/src/clj/pmagnus/btcprice/web/routes/ui.clj b/src/clj/pmagnus/btcprice/web/routes/ui.clj deleted file mode 100644 index eedf255..0000000 --- a/src/clj/pmagnus/btcprice/web/routes/ui.clj +++ /dev/null @@ -1,48 +0,0 @@ -(ns pmagnus.btcprice.web.routes.ui - (:require - [integrant.core :as ig] - [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 [_opts _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 {:hx-ext "ws" :ws-connect "/ws/price"} - [: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.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 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] - [["/" - {:get (fn [req] (home-page opts req))}]]) - -(defn route-data [opts] - (merge - opts - {:muuntaja formats/instance - :middleware [parameters/parameters-middleware - muuntaja/format-response-middleware - exception/exception-middleware]})) - -(derive :reitit.routes/ui :reitit/routes) - -(defmethod ig/init-key :reitit.routes/ui - [_ {:keys [base-path] - :or {base-path ""} - :as opts}] - [base-path (route-data opts) (ui-routes opts)]) diff --git a/src/clj/pmagnus/btcprice/web/routes/utils.clj b/src/clj/pmagnus/btcprice/web/routes/utils.clj deleted file mode 100644 index 8b12dc0..0000000 --- a/src/clj/pmagnus/btcprice/web/routes/utils.clj +++ /dev/null @@ -1,9 +0,0 @@ -(ns pmagnus.btcprice.web.routes.utils) - -(def route-data-path [:reitit.core/match :data]) - -(defn route-data [req] - (get-in req route-data-path)) - -(defn route-data-key [req k] - (get (route-data req) k)) diff --git a/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj b/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj deleted file mode 100644 index c720634..0000000 --- a/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj +++ /dev/null @@ -1,159 +0,0 @@ -(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] - (when (and ch (.isOpen ch)) - (try (.close ch) (catch Exception _)))) - -(defn- close-upstream-quietly [^java.net.http.WebSocket ws] - (when ws - (try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _)))) - -(defn- connect-upstream - "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] - (.request ws 1)) - (onText [_ ws data last?] - (let [text (str data)] - (try - (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)))) - (.request ws 1) - (CompletableFuture/completedFuture nil)) - (onClose [_ _ws status-code _reason] - (log/debug "Upstream closed" status-code) - (close-quietly browser-ch)) - (onError [_ _ws error] - (log/debug error "Upstream error") - (close-quietly browser-ch)))] - (-> (.newWebSocketBuilder client) - ^WebSocket$Builder identity - (.buildAsync (URI. btcdata-ws-url) listener) - (.join)))) - -(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 xf))) - :on-close-message - (fn [_] - (log/debug "Browser closed") - (close-upstream-quietly @upstream-atom))}})) - -;; --- 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" "\u20AC"] - ["USDT/EUR" ""] - ["EUR/BTC" ""]]) - -(defn- format-strike-amount [prefix amount] - (if amount - (let [v (double (bigdec amount))] - (case prefix - "$" (str prefix (da-fmt "%,.2f" v)) - "\u20AC" (str prefix (da-fmt "%,.2f" v)) - amount)) - "\u2014")) - -(defn- strike-json->html [text] - (let [rates (json/read-str text) - sats (get rates "quote-sats")] - (str "
" - "
" - (if sats - (str "

" - (da-fmt "%,d" sats) " sats

") - "") - "
" - (apply str - (for [[pair prefix] strike-pairs - :let [amount (get rates pair)]] - (str "
" - "" pair "" - "" - (format-strike-amount prefix amount) - "
"))) - "
"))) - -;; --- 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) - -(defmethod ig/init-key :reitit.routes/ws-proxy - [_ {:keys [base-path] - :or {base-path ""} - :as opts}] - [base-path {} (ws-proxy-routes opts)]) diff --git a/tailwind.config.js b/tailwind.config.js index ceef7fe..bf6dec9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,5 @@ module.exports = { - content: ["./src/**/*.clj", "./resources/**/*.html"], + content: ["./app/**/*.py", "./resources/**/*.html"], theme: { extend: {} }, plugins: [] } diff --git a/test/clj/pmagnus/btcprice/core_test.clj b/test/clj/pmagnus/btcprice/core_test.clj deleted file mode 100644 index f06537a..0000000 --- a/test/clj/pmagnus/btcprice/core_test.clj +++ /dev/null @@ -1,6 +0,0 @@ -(ns pmagnus.btcprice.core-test - (:require - [clojure.test :refer [deftest is]])) - -(deftest app-loads-test - (is (= 1 1))) diff --git a/test/clj/pmagnus/btcprice/test_utils.clj b/test/clj/pmagnus/btcprice/test_utils.clj deleted file mode 100644 index 13992f3..0000000 --- a/test/clj/pmagnus/btcprice/test_utils.clj +++ /dev/null @@ -1,21 +0,0 @@ -(ns pmagnus.btcprice.test-utils - (:require - [integrant.core :as ig] - [integrant.repl.state :as state] - [pmagnus.btcprice.config :as config] - [pmagnus.btcprice.core :as core])) - -(defn system-state [] - (or @core/system state/system)) - -(defn system-fixture [] - (fn [f] - (let [system (-> (config/system-config {:profile :test}) - (ig/prep) - (ig/init))] - (reset! core/system system) - (try - (f) - (finally - (ig/halt! system) - (reset! core/system nil)))))) diff --git a/test/clj/pmagnus/btcprice/web/request_test.clj b/test/clj/pmagnus/btcprice/web/request_test.clj deleted file mode 100644 index c3e0b27..0000000 --- a/test/clj/pmagnus/btcprice/web/request_test.clj +++ /dev/null @@ -1,13 +0,0 @@ -(ns pmagnus.btcprice.web.request-test - (:require - [clojure.test :refer [deftest is use-fixtures]] - [pmagnus.btcprice.test-utils :as tu])) - -(use-fixtures :once (tu/system-fixture)) - -(deftest health-request-test - (let [sys (tu/system-state) - app (get sys :handler/ring)] - (when app - (let [resp (app {:request-method :get :uri "/api/health"})] - (is (= 200 (:status resp)))))))