From 139c0286075fbaa71aa5d147b647f1f7dd332398 Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Sat, 14 Feb 2026 18:59:18 +0100 Subject: [PATCH] Format codebase with cljstyle Co-Authored-By: Claude Opus 4.6 --- src/clj/pmagnus/elprice/config.clj | 2 + src/clj/pmagnus/elprice/core.clj | 48 +++--- .../elprice/web/controllers/eloverblik.clj | 130 +++++++++------ .../elprice/web/controllers/health.clj | 4 +- .../elprice/web/controllers/prices.clj | 36 ++++- .../elprice/web/controllers/tariffs.clj | 13 +- src/clj/pmagnus/elprice/web/handler.clj | 45 +++--- src/clj/pmagnus/elprice/web/htmx.clj | 54 ++++--- .../pmagnus/elprice/web/middleware/core.clj | 6 +- .../elprice/web/middleware/exception.clj | 5 +- .../elprice/web/middleware/formats.clj | 1 + src/clj/pmagnus/elprice/web/routes/api.clj | 23 +-- src/clj/pmagnus/elprice/web/routes/ui.clj | 149 ++++++++++++------ src/clj/pmagnus/elprice/web/routes/utils.clj | 2 + 14 files changed, 327 insertions(+), 191 deletions(-) diff --git a/src/clj/pmagnus/elprice/config.clj b/src/clj/pmagnus/elprice/config.clj index 778b4ec..629540c 100644 --- a/src/clj/pmagnus/elprice/config.clj +++ b/src/clj/pmagnus/elprice/config.clj @@ -2,8 +2,10 @@ (: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/elprice/core.clj b/src/clj/pmagnus/elprice/core.clj index e334f79..9d364db 100644 --- a/src/clj/pmagnus/elprice/core.clj +++ b/src/clj/pmagnus/elprice/core.clj @@ -1,42 +1,48 @@ (ns pmagnus.elprice.core + (:gen-class) (:require - [clojure.tools.logging :as log] - [integrant.core :as ig] - [pmagnus.elprice.config :as config] - [pmagnus.elprice.env :refer [defaults]] - + [clojure.tools.logging :as log] + [integrant.core :as ig] + [kit.edge.db.postgres] ;; Edges - [kit.edge.db.sql.conman] - [kit.edge.db.sql.migratus] - [kit.edge.db.postgres] - [kit.edge.server.undertow] - [pmagnus.elprice.web.handler] - + [kit.edge.db.sql.conman] + [kit.edge.db.sql.migratus] + [kit.edge.server.undertow] + [pmagnus.elprice.config :as config] + [pmagnus.elprice.env :refer [defaults]] + [pmagnus.elprice.web.handler] ;; Routes - [pmagnus.elprice.web.routes.api] - [pmagnus.elprice.web.routes.ui]) - (:gen-class)) + [pmagnus.elprice.web.routes.api] + [pmagnus.elprice.web.routes.ui])) + ;; log uncaught exceptions in threads (Thread/setDefaultUncaughtExceptionHandler - (fn [thread ex] - (log/error {:what :uncaught-exception - :exception ex - :where (str "Uncaught exception on" (.getName thread))}))) + (fn [thread ex] + (log/error {:what :uncaught-exception + :exception ex + :where (str "Uncaught exception on" (.getName thread))}))) + (defonce system (atom nil)) -(defn stop-app [] + +(defn stop-app + [] ((or (:stop defaults) (fn []))) (some-> (deref system) (ig/halt!))) -(defn start-app [& [params]] + +(defn start-app + [& [params]] ((or (:start params) (:start defaults) (fn []))) (->> (config/system-config (or (:opts params) (:opts defaults) {})) (ig/expand) (ig/init) (reset! system))) -(defn -main [& _] + +(defn -main + [& _] (start-app) (.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents))))) diff --git a/src/clj/pmagnus/elprice/web/controllers/eloverblik.clj b/src/clj/pmagnus/elprice/web/controllers/eloverblik.clj index 2825c57..c424cf0 100644 --- a/src/clj/pmagnus/elprice/web/controllers/eloverblik.clj +++ b/src/clj/pmagnus/elprice/web/controllers/eloverblik.clj @@ -1,24 +1,39 @@ (ns pmagnus.elprice.web.controllers.eloverblik (:require - [clojure.data.json :as json] - [clojure.string :as str] - [clojure.tools.logging :as log]) + [clojure.data.json :as json] + [clojure.string :as str] + [clojure.tools.logging :as log]) (:import - [java.net URI] - [java.net.http HttpClient HttpRequest HttpRequest$BodyPublishers HttpResponse$BodyHandlers] - [java.time LocalDate LocalDateTime ZoneId] - [java.time.format DateTimeFormatter] - [java.sql Timestamp Date])) + (java.net + URI) + (java.net.http + HttpClient + HttpRequest + HttpRequest$BodyPublishers + HttpResponse$BodyHandlers) + (java.sql + Date + Timestamp) + (java.time + LocalDate + LocalDateTime + ZoneId) + (java.time.format + DateTimeFormatter))) + (def ^:private base-url "https://api.eloverblik.dk/customerapi") (def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) + ;; --- Auth --- -(defn- read-refresh-token [] +(defn- read-refresh-token + [] (str/trim (slurp ".token"))) + (defn- get-access-token "Exchange refresh token for a short-lived access token." [refresh-token] @@ -34,6 +49,7 @@ (let [body (json/read-str (.body response) :key-fn keyword)] (:result body))))) + ;; --- HTTP helpers --- (defn- api-get @@ -51,6 +67,7 @@ (when (= 200 (.statusCode response)) (json/read-str (.body response) :key-fn keyword)))) + (defn- api-post "POST with Bearer access token + JSON body, returns parsed JSON." [access-token path body] @@ -68,17 +85,23 @@ (when (= 200 (.statusCode response)) (json/read-str (.body response) :key-fn keyword)))) + ;; --- Metering Points --- -(defn- fetch-metering-points [access-token] +(defn- fetch-metering-points + [access-token] (let [data (api-get access-token "/api/meteringpoints/meteringpoints?includeAll=true")] (:result data))) -(defn- parse-date [s] + +(defn- parse-date + [s] (when (and s (not (str/blank? s))) (Date/valueOf (LocalDate/parse (subs s 0 10))))) -(defn- upsert-mp! [query-fn mp] + +(defn- upsert-mp! + [query-fn mp] (query-fn :upsert-metering-point! {:metering-point-id (:meteringPointId mp) :type-of-mp (:typeOfMP mp) @@ -93,7 +116,9 @@ :consumer-start-date (parse-date (:consumerStartDate mp)) :first-consumer-name (:firstConsumerPartyName mp)})) -(defn- save-metering-points! [query-fn metering-points] + +(defn- save-metering-points! + [query-fn metering-points] (let [all-mps (mapcat (fn [mp] (cons mp (:childMeteringPoints mp))) metering-points)] @@ -101,53 +126,58 @@ (upsert-mp! query-fn mp)) (log/info "Saved" (count all-mps) "metering points"))) + ;; --- Time Series --- -(defn- fetch-time-series [access-token metering-point-ids from to] +(defn- fetch-time-series + [access-token metering-point-ids from to] (api-post access-token (str "/api/meterdata/gettimeseries/" (.format from date-fmt) "/" (.format to date-fmt) "/Hour") {:meteringPoints {:meteringPoint metering-point-ids}})) + (defn- parse-time-series "Parse the nested time series response into flat reading maps." [response metering-point-ids] (let [results (:result response)] (mapcat - (fn [result mp-id] - (let [doc (:MyEnergyData_MarketDocument result) - series (or (:TimeSeries doc) [])] - (mapcat - (fn [ts] - (mapcat - (fn [period] - (let [start-str (get-in period [:timeInterval :start]) - start (LocalDateTime/parse - start-str - (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ss'Z'")) - start-dk (-> start - (.atZone (ZoneId/of "UTC")) - (.withZoneSameInstant dk-zone) - (.toLocalDateTime))] - (map (fn [point] - (let [pos (dec (Long/parseLong (str (:position point)))) - time-dk (.plusHours start-dk pos) - raw-qty (get point (keyword "out_Quantity.quantity")) - quantity (when raw-qty (Double/parseDouble (str raw-qty)))] - {:metering-point-id mp-id - :time-dk (Timestamp/valueOf time-dk) - :quantity-kwh quantity - :quality (get point (keyword "out_Quantity.quality"))})) - (:Point period)))) - (:Period ts))) - series))) - results - metering-point-ids))) + (fn [result mp-id] + (let [doc (:MyEnergyData_MarketDocument result) + series (or (:TimeSeries doc) [])] + (mapcat + (fn [ts] + (mapcat + (fn [period] + (let [start-str (get-in period [:timeInterval :start]) + start (LocalDateTime/parse + start-str + (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ss'Z'")) + start-dk (-> start + (.atZone (ZoneId/of "UTC")) + (.withZoneSameInstant dk-zone) + (.toLocalDateTime))] + (map (fn [point] + (let [pos (dec (Long/parseLong (str (:position point)))) + time-dk (.plusHours start-dk pos) + raw-qty (get point (keyword "out_Quantity.quantity")) + quantity (when raw-qty (Double/parseDouble (str raw-qty)))] + {:metering-point-id mp-id + :time-dk (Timestamp/valueOf time-dk) + :quantity-kwh quantity + :quality (get point (keyword "out_Quantity.quality"))})) + (:Point period)))) + (:Period ts))) + series))) + results + metering-point-ids))) + (def ^:private production-mp-id "571313113163368491") (def ^:private consumption-mp-id "571313113163368507") + (defn- utc-start-for-date "Compute the UTC start of a Danish date (midnight DK -> UTC)." [^LocalDate date] @@ -156,7 +186,9 @@ (.toInstant) (Timestamp/from))) -(defn- save-time-series! [query-fn readings from] + +(defn- save-time-series! + [query-fn readings from] (doseq [r readings] (query-fn :insert-meter-reading! r)) (let [time-start (utc-start-for-date from)] @@ -170,14 +202,18 @@ {:time-start time-start :hour hour :kwh (:quantity-kwh r)})))) (log/info "Saved" (count readings) "meter readings + production/consumption")) + ;; --- Charges --- -(defn- fetch-charges [access-token metering-point-ids] +(defn- fetch-charges + [access-token metering-point-ids] (api-post access-token "/api/meteringpoints/meteringpoint/getcharges" {:meteringPoints {:meteringPoint metering-point-ids}})) -(defn- save-charges! [query-fn charges-response] + +(defn- save-charges! + [query-fn charges-response] (let [results (:result charges-response)] (doseq [result results] (let [mp-id (:id result) @@ -240,6 +276,7 @@ :quantity (:quantity fee) :position 0})))))) + ;; --- Orchestrator --- (defn fetch-and-save-all! @@ -286,4 +323,3 @@ {:metering-points (count all-ids) :from (.format from date-fmt) :to (.format to date-fmt)}))))))) - diff --git a/src/clj/pmagnus/elprice/web/controllers/health.clj b/src/clj/pmagnus/elprice/web/controllers/health.clj index 0c1eb1b..3141aaf 100644 --- a/src/clj/pmagnus/elprice/web/controllers/health.clj +++ b/src/clj/pmagnus/elprice/web/controllers/health.clj @@ -2,7 +2,9 @@ (:require [ring.util.http-response :as http-response]) (:import - [java.util Date])) + (java.util + Date))) + (defn healthcheck! [req] diff --git a/src/clj/pmagnus/elprice/web/controllers/prices.clj b/src/clj/pmagnus/elprice/web/controllers/prices.clj index 6261d04..72e5b16 100644 --- a/src/clj/pmagnus/elprice/web/controllers/prices.clj +++ b/src/clj/pmagnus/elprice/web/controllers/prices.clj @@ -1,18 +1,29 @@ (ns pmagnus.elprice.web.controllers.prices (:require - [clojure.data.json :as json] - [clojure.tools.logging :as log]) + [clojure.data.json :as json] + [clojure.tools.logging :as log]) (:import - [java.net URI URLEncoder] - [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] - [java.time LocalDate ZoneId] - [java.time.format DateTimeFormatter] - [java.sql Timestamp])) + (java.net + URI + URLEncoder) + (java.net.http + HttpClient + HttpRequest + HttpResponse$BodyHandlers) + (java.sql + Timestamp) + (java.time + LocalDate + ZoneId) + (java.time.format + DateTimeFormatter))) + (def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) + (defn- build-url "Build the Energi Data Service API URL for day-ahead prices." [start-date end-date price-area] @@ -23,6 +34,7 @@ "&sort=TimeDK%20asc" "&limit=200")) + (defn- fetch-json "Fetch JSON from a URL using JDK HttpClient." [url] @@ -36,6 +48,7 @@ (when (= 200 (.statusCode response)) (json/read-str (.body response) :key-fn keyword)))) + (defn- fetch-area "Fetch today's and tomorrow's prices for a single price area." [start end price-area] @@ -44,6 +57,7 @@ (when-let [data (fetch-json url)] (:records data)))) + (defn fetch-prices-from-api "Fetch today's and tomorrow's prices from Energi Data Service for DK1 and DK2." [] @@ -53,12 +67,14 @@ (concat (fetch-area start end "DK1") (fetch-area start end "DK2")))) + (defn- parse-timestamp "Parse an ISO timestamp string to java.sql.Timestamp." [s] (when s (Timestamp/valueOf - (.replace ^String s "T" " ")))) + (.replace ^String s "T" " ")))) + (defn save-records! "Insert price records into the database, skipping existing ones." @@ -71,6 +87,7 @@ :price-dkk (:DayAheadPriceDKK rec) :price-eur (:DayAheadPriceEUR rec)}))) + (defn fetch-and-save! "Fetch prices from API and save to database." [query-fn] @@ -79,6 +96,7 @@ (save-records! query-fn records) (count records))) + (defn get-prices-for-date "Get prices for a given LocalDate and price area from the database." [query-fn date price-area] @@ -86,10 +104,12 @@ {:date (java.sql.Date/valueOf date) :price-area price-area})) + (defn get-today-prices [query-fn] (get-prices-for-date query-fn (LocalDate/now dk-zone) "DK1")) + (defn get-tomorrow-prices [query-fn] (get-prices-for-date query-fn (.plusDays (LocalDate/now dk-zone) 1) "DK1")) diff --git a/src/clj/pmagnus/elprice/web/controllers/tariffs.clj b/src/clj/pmagnus/elprice/web/controllers/tariffs.clj index 901f94f..cca9d2c 100644 --- a/src/clj/pmagnus/elprice/web/controllers/tariffs.clj +++ b/src/clj/pmagnus/elprice/web/controllers/tariffs.clj @@ -1,22 +1,26 @@ (ns pmagnus.elprice.web.controllers.tariffs (:import - [java.time LocalDateTime])) + (java.time + LocalDateTime))) + (def n1-tariffs "N1 network tariffs in øre/kWh (incl. VAT), effective 2026-01-01. Source: https://n1.dk/elnetkunder/gaeldende-priser" - {:summer {:low 10.98 ;; 00:00-06:00 - :high 16.47 ;; 06:00-17:00, 21:00-24:00 - :peak 42.83} ;; 17:00-21:00 + {:summer {:low 10.98 ; 00:00-06:00 + :high 16.47 ; 06:00-17:00, 21:00-24:00 + :peak 42.83} ; 17:00-21:00 :winter {:low 10.98 :high 32.95 :peak 98.84}}) + (defn season "Returns :summer (Apr-Sep) or :winter (Oct-Mar) for a given month (1-12)." [month] (if (<= 4 month 9) :summer :winter)) + (defn load-level "Returns :low, :high, or :peak for a given hour (0-23). Low: 00:00-06:00 @@ -29,6 +33,7 @@ (and (<= 17 hour) (< hour 21)) :peak :else :high)) + (defn tariff-for-hour "Returns the applicable N1 tariff in øre/kWh. Can be called with (month, hour) or a LocalDateTime." diff --git a/src/clj/pmagnus/elprice/web/handler.clj b/src/clj/pmagnus/elprice/web/handler.clj index 294c255..b9e5005 100644 --- a/src/clj/pmagnus/elprice/web/handler.clj +++ b/src/clj/pmagnus/elprice/web/handler.clj @@ -1,35 +1,37 @@ (ns pmagnus.elprice.web.handler (:require - [pmagnus.elprice.web.middleware.core :as middleware] [integrant.core :as ig] - [ring.util.http-response :as http-response] + [pmagnus.elprice.web.middleware.core :as middleware] [reitit.ring :as ring] - [reitit.swagger-ui :as swagger-ui])) + [reitit.swagger-ui :as swagger-ui] + [ring.util.http-response :as http-response])) + (defmethod ig/init-key :handler/ring [_ {:keys [router api-path] :as opts}] (ring/ring-handler - (router) + (router) (ring/routes - ;; Handle trailing slash in routes - add it + redirect to it - ;; https://github.com/metosin/reitit/blob/master/doc/ring/slash_handler.md - (ring/redirect-trailing-slash-handler) - (ring/create-resource-handler {:path "/"}) - (when (some? api-path) - (swagger-ui/create-swagger-ui-handler {:path api-path - :url (str api-path "/swagger.json")})) - (ring/create-default-handler - {:not-found - (constantly (-> {:status 404, :body "Page not found"} - (http-response/content-type "text/plain"))) - :method-not-allowed - (constantly (-> {:status 405, :body "Not allowed"} - (http-response/content-type "text/plain"))) - :not-acceptable - (constantly (-> {:status 406, :body "Not acceptable"} - (http-response/content-type "text/plain")))})) + ;; Handle trailing slash in routes - add it + redirect to it + ;; https://github.com/metosin/reitit/blob/master/doc/ring/slash_handler.md + (ring/redirect-trailing-slash-handler) + (ring/create-resource-handler {:path "/"}) + (when (some? api-path) + (swagger-ui/create-swagger-ui-handler {:path api-path + :url (str api-path "/swagger.json")})) + (ring/create-default-handler + {:not-found + (constantly (-> {:status 404, :body "Page not found"} + (http-response/content-type "text/plain"))) + :method-not-allowed + (constantly (-> {:status 405, :body "Not allowed"} + (http-response/content-type "text/plain"))) + :not-acceptable + (constantly (-> {:status 406, :body "Not acceptable"} + (http-response/content-type "text/plain")))})) {:middleware [(middleware/wrap-base opts)]})) + (defmethod ig/init-key :router/routes [_ {:keys [routes]}] (mapv (fn [route] @@ -38,6 +40,7 @@ route)) routes)) + (defmethod ig/init-key :router/core [_ {:keys [routes env] :as opts}] (if (= env :dev) diff --git a/src/clj/pmagnus/elprice/web/htmx.clj b/src/clj/pmagnus/elprice/web/htmx.clj index 34ac3ac..9ceedc6 100644 --- a/src/clj/pmagnus/elprice/web/htmx.clj +++ b/src/clj/pmagnus/elprice/web/htmx.clj @@ -1,25 +1,29 @@ -(ns pmagnus.elprice.web.htmx - (:require - [ring.util.http-response :as http-response] - [hiccup.core :as h] - [hiccup.page :as p])) - -(defmacro page [opts & content] - `(-> (p/html5 ~opts - [:head - [:meta {:charset "UTF-8"}] - [:meta {:name "viewport" - :content "width=device-width, initial-scale=1, viewport-fit=cover"}] - [:link {:rel "stylesheet" :href "/css/output.css"}] - [:script {:src "https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js" - :defer true}]] - [:body {:class "bg-gray-50 min-h-screen"} - [:div {:class "mx-auto max-w-[390px] px-4 py-6"} - ~@content]]) - http-response/ok - (http-response/content-type "text/html"))) - -(defmacro fragment [opts & content] - `(-> (str (h/html ~opts ~@content)) - http-response/ok - (http-response/content-type "text/html"))) +(ns pmagnus.elprice.web.htmx + (:require + [hiccup.core :as h] + [hiccup.page :as p] + [ring.util.http-response :as http-response])) + + +(defmacro page + [opts & content] + `(-> (p/html5 ~opts + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" + :content "width=device-width, initial-scale=1, viewport-fit=cover"}] + [:link {:rel "stylesheet" :href "/css/output.css"}] + [:script {:src "https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js" + :defer true}]] + [:body {:class "bg-gray-50 min-h-screen"} + [:div {:class "mx-auto max-w-[390px] px-4 py-6"} + ~@content]]) + http-response/ok + (http-response/content-type "text/html"))) + + +(defmacro fragment + [opts & content] + `(-> (str (h/html ~opts ~@content)) + http-response/ok + (http-response/content-type "text/html"))) diff --git a/src/clj/pmagnus/elprice/web/middleware/core.clj b/src/clj/pmagnus/elprice/web/middleware/core.clj index ee994cb..14e3f92 100644 --- a/src/clj/pmagnus/elprice/web/middleware/core.clj +++ b/src/clj/pmagnus/elprice/web/middleware/core.clj @@ -4,11 +4,11 @@ [ring.middleware.defaults :as defaults] [ring.middleware.session.cookie :as cookie])) + (defn wrap-base [{:keys [metrics site-defaults-config cookie-secret] :as opts}] (let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})] (fn [handler] (cond-> ((:middleware env/defaults) handler opts) - true (defaults/wrap-defaults - (assoc-in site-defaults-config [:session :store] cookie-store)) - )))) + true (defaults/wrap-defaults + (assoc-in site-defaults-config [:session :store] cookie-store)))))) diff --git a/src/clj/pmagnus/elprice/web/middleware/exception.clj b/src/clj/pmagnus/elprice/web/middleware/exception.clj index 4839105..8cb72c4 100644 --- a/src/clj/pmagnus/elprice/web/middleware/exception.clj +++ b/src/clj/pmagnus/elprice/web/middleware/exception.clj @@ -3,7 +3,9 @@ [clojure.tools.logging :as log] [reitit.ring.middleware.exception :as exception])) -(defn handler [message status exception request] + +(defn handler + [message status exception request] (when (>= status 500) ;; You can optionally use this to report error to an external service (log/error exception)) @@ -13,6 +15,7 @@ :data (ex-data exception) :uri (:uri request)}}) + (def wrap-exception (exception/create-exception-middleware (merge diff --git a/src/clj/pmagnus/elprice/web/middleware/formats.clj b/src/clj/pmagnus/elprice/web/middleware/formats.clj index 5d3644d..a173d03 100644 --- a/src/clj/pmagnus/elprice/web/middleware/formats.clj +++ b/src/clj/pmagnus/elprice/web/middleware/formats.clj @@ -3,6 +3,7 @@ [luminus-transit.time :as time] [muuntaja.core :as m])) + (def instance (m/create (-> m/default-options diff --git a/src/clj/pmagnus/elprice/web/routes/api.clj b/src/clj/pmagnus/elprice/web/routes/api.clj index cccfb24..d49b548 100644 --- a/src/clj/pmagnus/elprice/web/routes/api.clj +++ b/src/clj/pmagnus/elprice/web/routes/api.clj @@ -1,38 +1,41 @@ (ns pmagnus.elprice.web.routes.api (:require + [integrant.core :as ig] [pmagnus.elprice.web.controllers.health :as health] [pmagnus.elprice.web.middleware.exception :as exception] [pmagnus.elprice.web.middleware.formats :as formats] - [integrant.core :as ig] [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])) + (def route-data {:coercion malli/coercion :muuntaja formats/instance :swagger {:id ::api} :middleware [;; query-params & form-params parameters/parameters-middleware - ;; content-negotiation + ;; content-negotiation muuntaja/format-negotiate-middleware - ;; encoding response body + ;; encoding response body muuntaja/format-response-middleware - ;; exception handling + ;; exception handling coercion/coerce-exceptions-middleware - ;; decoding request body + ;; decoding request body muuntaja/format-request-middleware - ;; coercing response bodys + ;; coercing response bodys coercion/coerce-response-middleware - ;; coercing request parameters + ;; coercing request parameters coercion/coerce-request-middleware - ;; exception handling + ;; exception handling exception/wrap-exception]}) + ;; Routes -(defn api-routes [_opts] +(defn api-routes + [_opts] [["/swagger.json" {:get {:no-doc true :swagger {:info {:title "pmagnus.elprice API"}} @@ -43,8 +46,10 @@ ;; restarting the system {:get #'health/healthcheck!}]]) + (derive :reitit.routes/api :reitit/routes) + (defmethod ig/init-key :reitit.routes/api [_ {:keys [base-path] :or {base-path ""} diff --git a/src/clj/pmagnus/elprice/web/routes/ui.clj b/src/clj/pmagnus/elprice/web/routes/ui.clj index 6f60b6c..fe9b66e 100644 --- a/src/clj/pmagnus/elprice/web/routes/ui.clj +++ b/src/clj/pmagnus/elprice/web/routes/ui.clj @@ -1,39 +1,49 @@ (ns pmagnus.elprice.web.routes.ui (:require - [pmagnus.elprice.web.middleware.exception :as exception] - [pmagnus.elprice.web.middleware.formats :as formats] - [pmagnus.elprice.web.routes.utils :as utils] - [pmagnus.elprice.web.htmx :refer [page fragment]] - [pmagnus.elprice.web.controllers.prices :as prices] - [pmagnus.elprice.web.controllers.tariffs :as tariffs] - [pmagnus.elprice.web.controllers.eloverblik :as eloverblik] - [integrant.core :as ig] - [reitit.ring.middleware.muuntaja :as muuntaja] - [reitit.ring.middleware.parameters :as parameters]) + [integrant.core :as ig] + [pmagnus.elprice.web.controllers.eloverblik :as eloverblik] + [pmagnus.elprice.web.controllers.prices :as prices] + [pmagnus.elprice.web.controllers.tariffs :as tariffs] + [pmagnus.elprice.web.htmx :refer [page fragment]] + [pmagnus.elprice.web.middleware.exception :as exception] + [pmagnus.elprice.web.middleware.formats :as formats] + [pmagnus.elprice.web.routes.utils :as utils] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.parameters :as parameters]) (:import - [java.time LocalDateTime ZoneId] - [java.time.format DateTimeFormatter])) + (java.time + LocalDateTime + ZoneId) + (java.time.format + DateTimeFormatter))) + (def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) (def ^:private hour-fmt (DateTimeFormatter/ofPattern "HH:00")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) -(defn- ->ldt [time-dk] + +(defn- ->ldt + [time-dk] (cond (instance? LocalDateTime time-dk) time-dk (instance? java.sql.Timestamp time-dk) (.toLocalDateTime ^java.sql.Timestamp time-dk) :else (LocalDateTime/parse (str time-dk)))) -(defn- format-hour [time-dk] + +(defn- format-hour + [time-dk] (.format (->ldt time-dk) hour-fmt)) + (defn- dkk-mwh->ore-kwh "Convert DKK/MWh to øre/kWh (divide by 10)." [price-dkk] (when price-dkk (/ (double price-dkk) 10.0))) + (defn- format-ore "Format price as total(net) where total = net + tariff, or just net if no tariff." ([price-dkk] @@ -45,31 +55,41 @@ (format "%.1f(%.1f)" (+ ore tariff) ore) "-"))) -(defn- group-by-hour [prices] + +(defn- group-by-hour + [prices] (->> prices (group-by #(.getHour (->ldt (:time_dk %)))) (sort-by key))) -(defn- avg-ore [quarters] + +(defn- avg-ore + [quarters] (let [vals (keep :price_dkk quarters)] (when (seq vals) (/ (reduce + 0.0 (map double vals)) (count vals) 10.0)))) + (def ^:private red-gradient ["bg-red-50" "bg-red-100" "bg-red-200" "bg-red-300" "bg-red-400" "bg-red-500" "bg-red-600" "bg-red-700"]) + (def ^:private green-gradient ["bg-green-700" "bg-green-600" "bg-green-500" "bg-green-400" "bg-green-300" "bg-green-200" "bg-green-100" "bg-green-50"]) -(defn- rank-bg [rank total] + +(defn- rank-bg + [rank total] (cond (<= rank 8) (get green-gradient (dec rank)) (> rank (- total 8)) (get red-gradient (- rank (- total 7))) :else nil)) -(defn- hour-block [hour quarters rank total-hours] + +(defn- hour-block + [hour quarters rank total-hours] (let [sorted (sort-by #(.getMinute (->ldt (:time_dk %))) quarters) avg (avg-ore quarters) month (.getMonthValue (->ldt (:time_dk (first quarters)))) @@ -90,6 +110,7 @@ [:span (format "%02d:%02d" hour (.getMinute (->ldt (:time_dk q))))] [:span {:class "font-mono"} (format-ore (:price_dkk q) tariff)]])]])) + (defn- hour-ranks "Compute a map of hour -> rank (1 = cheapest) based on total price (avg + tariff)." [grouped-hours] @@ -103,7 +124,9 @@ (map-indexed (fn [i [hour _]] [hour (inc i)])) (into {})))) -(defn- price-table [prices] + +(defn- price-table + [prices] [:div (if (seq prices) (let [grouped (group-by-hour prices) @@ -114,16 +137,21 @@ (hour-block hour quarters (get ranks hour) total-hours))]) [:p {:class "mt-2 text-sm text-gray-400"} "Not available yet"])]) + (def ^:private tab-base "px-4 py-2 font-medium text-sm rounded-t-lg border-2 border-b-0 ") + (def ^:private tab-active (str tab-base "border-blue-400 bg-white text-gray-900")) + (def ^:private tab-inactive (str tab-base "border-gray-200 bg-gray-100 text-gray-500 hover:text-gray-700 cursor-pointer")) -(defn- tabs [active-tab has-tomorrow?] + +(defn- tabs + [active-tab has-tomorrow?] (let [today (java.time.LocalDate/now dk-zone) tomorrow (.plusDays today 1)] [:div {:class "flex gap-1 mt-4"} @@ -139,69 +167,86 @@ :hx-swap "innerHTML"} (str "Tomorrow " (.format tomorrow date-fmt))])])) -(defn- price-content [tab prices has-tomorrow?] + +(defn- price-content + [tab prices has-tomorrow?] [:div (tabs tab has-tomorrow?) [:div {:id "price-content" :class "border-2 border-blue-400 rounded-b-lg rounded-tr-lg p-4 bg-white"} (price-table prices)]]) -(defn- has-tomorrow? [query-fn] + +(defn- has-tomorrow? + [query-fn] (seq (prices/get-tomorrow-prices query-fn))) -(defn home [query-fn request] + +(defn home + [query-fn request] (prices/fetch-and-save! query-fn) (page {:lang "en"} - [:h1 {:class "text-2xl font-bold text-gray-900 cursor-pointer hover:text-blue-600 transition-colors" - :hx-post "/prices/fetch" - :hx-target "#price-panel" - :hx-swap "innerHTML"} - "Electricity Prices"] - [:p {:class "mt-1 text-sm text-gray-500"} - "Day-ahead prices for DK1 — total(spot) in øre/kWh incl. N1 tariff"] - [:div {:id "price-panel"} - (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn))])) + [:h1 {:class "text-2xl font-bold text-gray-900 cursor-pointer hover:text-blue-600 transition-colors" + :hx-post "/prices/fetch" + :hx-target "#price-panel" + :hx-swap "innerHTML"} + "Electricity Prices"] + [:p {:class "mt-1 text-sm text-gray-500"} + "Day-ahead prices for DK1 — total(spot) in øre/kWh incl. N1 tariff"] + [:div {:id "price-panel"} + (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn))])) -(defn prices-today [query-fn request] + +(defn prices-today + [query-fn request] (fragment - (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn)))) + (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn)))) -(defn prices-tomorrow [query-fn request] + +(defn prices-tomorrow + [query-fn request] (fragment - (price-content :tomorrow (prices/get-tomorrow-prices query-fn) (has-tomorrow? query-fn)))) + (price-content :tomorrow (prices/get-tomorrow-prices query-fn) (has-tomorrow? query-fn)))) -(defn fetch-prices [query-fn request] + +(defn fetch-prices + [query-fn request] (prices/fetch-and-save! query-fn) (fragment - (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn)))) + (price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn)))) -(defn sync-eloverblik [query-fn request] + +(defn sync-eloverblik + [query-fn request] (let [date-str (get-in request [:params :date]) date (if date-str (java.time.LocalDate/parse date-str) (java.time.LocalDate/now dk-zone)) result (eloverblik/fetch-and-save-all! query-fn date)] (fragment - (cond - (:error result) - [:p {:class "text-red-600 font-medium"} (:error result)] - (:no-data result) - [:p {:class "text-gray-400"} (str "No data to fetch for " (:date result))] - (:exists result) - [:p {:class "text-gray-500"} (str "Data exists for " (:date result))] - :else - [:p {:class "text-green-700 font-medium"} - (str "Synced " (:metering-points result) - " metering points (" (:from result) " to " (:to result) ")")])))) + (cond + (:error result) + [:p {:class "text-red-600 font-medium"} (:error result)] + (:no-data result) + [:p {:class "text-gray-400"} (str "No data to fetch for " (:date result))] + (:exists result) + [:p {:class "text-gray-500"} (str "Data exists for " (:date result))] + :else + [:p {:class "text-green-700 font-medium"} + (str "Synced " (:metering-points result) + " metering points (" (:from result) " to " (:to result) ")")])))) + ;; Routes -(defn ui-routes [{:keys [query-fn]}] +(defn ui-routes + [{:keys [query-fn]}] [["/" {:get (partial home query-fn)}] ["/prices/today" {:get (partial prices-today query-fn)}] ["/prices/tomorrow" {:get (partial prices-tomorrow query-fn)}] ["/prices/fetch" {:post (partial fetch-prices query-fn)}] ["/eloverblik/sync" {:post (partial sync-eloverblik query-fn)}]]) + (def route-data {:muuntaja formats/instance :middleware @@ -213,8 +258,10 @@ ;; exception handling exception/wrap-exception]}) + (derive :reitit.routes/ui :reitit/routes) + (defmethod ig/init-key :reitit.routes/ui [_ {:keys [base-path] :or {base-path ""} diff --git a/src/clj/pmagnus/elprice/web/routes/utils.clj b/src/clj/pmagnus/elprice/web/routes/utils.clj index 9869eab..05d082a 100644 --- a/src/clj/pmagnus/elprice/web/routes/utils.clj +++ b/src/clj/pmagnus/elprice/web/routes/utils.clj @@ -2,10 +2,12 @@ (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-in req (conj route-data-path k)))