Format codebase with cljstyle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 18:59:18 +01:00
co-authored by Claude Opus 4.6
parent d0ff9b485d
commit 139c028607
14 changed files with 327 additions and 191 deletions
+2
View File
@@ -2,8 +2,10 @@
(:require (:require
[kit.config :as config])) [kit.config :as config]))
(def ^:const system-filename "system.edn") (def ^:const system-filename "system.edn")
(defn system-config (defn system-config
[options] [options]
(config/read-config system-filename options)) (config/read-config system-filename options))
+16 -10
View File
@@ -1,21 +1,20 @@
(ns pmagnus.elprice.core (ns pmagnus.elprice.core
(:gen-class)
(:require (:require
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[pmagnus.elprice.config :as config] [kit.edge.db.postgres]
[pmagnus.elprice.env :refer [defaults]]
;; Edges ;; Edges
[kit.edge.db.sql.conman] [kit.edge.db.sql.conman]
[kit.edge.db.sql.migratus] [kit.edge.db.sql.migratus]
[kit.edge.db.postgres]
[kit.edge.server.undertow] [kit.edge.server.undertow]
[pmagnus.elprice.config :as config]
[pmagnus.elprice.env :refer [defaults]]
[pmagnus.elprice.web.handler] [pmagnus.elprice.web.handler]
;; Routes ;; Routes
[pmagnus.elprice.web.routes.api] [pmagnus.elprice.web.routes.api]
[pmagnus.elprice.web.routes.ui]) [pmagnus.elprice.web.routes.ui]))
(:gen-class))
;; log uncaught exceptions in threads ;; log uncaught exceptions in threads
(Thread/setDefaultUncaughtExceptionHandler (Thread/setDefaultUncaughtExceptionHandler
@@ -24,19 +23,26 @@
:exception ex :exception ex
:where (str "Uncaught exception on" (.getName thread))}))) :where (str "Uncaught exception on" (.getName thread))})))
(defonce system (atom nil)) (defonce system (atom nil))
(defn stop-app []
(defn stop-app
[]
((or (:stop defaults) (fn []))) ((or (:stop defaults) (fn [])))
(some-> (deref system) (ig/halt!))) (some-> (deref system) (ig/halt!)))
(defn start-app [& [params]]
(defn start-app
[& [params]]
((or (:start params) (:start defaults) (fn []))) ((or (:start params) (:start defaults) (fn [])))
(->> (config/system-config (or (:opts params) (:opts defaults) {})) (->> (config/system-config (or (:opts params) (:opts defaults) {}))
(ig/expand) (ig/expand)
(ig/init) (ig/init)
(reset! system))) (reset! system)))
(defn -main [& _]
(defn -main
[& _]
(start-app) (start-app)
(.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents))))) (.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents)))))
@@ -4,21 +4,36 @@
[clojure.string :as str] [clojure.string :as str]
[clojure.tools.logging :as log]) [clojure.tools.logging :as log])
(:import (:import
[java.net URI] (java.net
[java.net.http HttpClient HttpRequest HttpRequest$BodyPublishers HttpResponse$BodyHandlers] URI)
[java.time LocalDate LocalDateTime ZoneId] (java.net.http
[java.time.format DateTimeFormatter] HttpClient
[java.sql Timestamp Date])) 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 base-url "https://api.eloverblik.dk/customerapi")
(def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) (def ^:private dk-zone (ZoneId/of "Europe/Copenhagen"))
(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd"))
;; --- Auth --- ;; --- Auth ---
(defn- read-refresh-token [] (defn- read-refresh-token
[]
(str/trim (slurp ".token"))) (str/trim (slurp ".token")))
(defn- get-access-token (defn- get-access-token
"Exchange refresh token for a short-lived access token." "Exchange refresh token for a short-lived access token."
[refresh-token] [refresh-token]
@@ -34,6 +49,7 @@
(let [body (json/read-str (.body response) :key-fn keyword)] (let [body (json/read-str (.body response) :key-fn keyword)]
(:result body))))) (:result body)))))
;; --- HTTP helpers --- ;; --- HTTP helpers ---
(defn- api-get (defn- api-get
@@ -51,6 +67,7 @@
(when (= 200 (.statusCode response)) (when (= 200 (.statusCode response))
(json/read-str (.body response) :key-fn keyword)))) (json/read-str (.body response) :key-fn keyword))))
(defn- api-post (defn- api-post
"POST with Bearer access token + JSON body, returns parsed JSON." "POST with Bearer access token + JSON body, returns parsed JSON."
[access-token path body] [access-token path body]
@@ -68,17 +85,23 @@
(when (= 200 (.statusCode response)) (when (= 200 (.statusCode response))
(json/read-str (.body response) :key-fn keyword)))) (json/read-str (.body response) :key-fn keyword))))
;; --- Metering Points --- ;; --- 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")] (let [data (api-get access-token "/api/meteringpoints/meteringpoints?includeAll=true")]
(:result data))) (:result data)))
(defn- parse-date [s]
(defn- parse-date
[s]
(when (and s (not (str/blank? s))) (when (and s (not (str/blank? s)))
(Date/valueOf (LocalDate/parse (subs s 0 10))))) (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! (query-fn :upsert-metering-point!
{:metering-point-id (:meteringPointId mp) {:metering-point-id (:meteringPointId mp)
:type-of-mp (:typeOfMP mp) :type-of-mp (:typeOfMP mp)
@@ -93,7 +116,9 @@
:consumer-start-date (parse-date (:consumerStartDate mp)) :consumer-start-date (parse-date (:consumerStartDate mp))
:first-consumer-name (:firstConsumerPartyName 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] (let [all-mps (mapcat (fn [mp]
(cons mp (:childMeteringPoints mp))) (cons mp (:childMeteringPoints mp)))
metering-points)] metering-points)]
@@ -101,15 +126,18 @@
(upsert-mp! query-fn mp)) (upsert-mp! query-fn mp))
(log/info "Saved" (count all-mps) "metering points"))) (log/info "Saved" (count all-mps) "metering points")))
;; --- Time Series --- ;; --- 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 (api-post access-token
(str "/api/meterdata/gettimeseries/" (str "/api/meterdata/gettimeseries/"
(.format from date-fmt) "/" (.format from date-fmt) "/"
(.format to date-fmt) "/Hour") (.format to date-fmt) "/Hour")
{:meteringPoints {:meteringPoint metering-point-ids}})) {:meteringPoints {:meteringPoint metering-point-ids}}))
(defn- parse-time-series (defn- parse-time-series
"Parse the nested time series response into flat reading maps." "Parse the nested time series response into flat reading maps."
[response metering-point-ids] [response metering-point-ids]
@@ -145,9 +173,11 @@
results results
metering-point-ids))) metering-point-ids)))
(def ^:private production-mp-id "571313113163368491") (def ^:private production-mp-id "571313113163368491")
(def ^:private consumption-mp-id "571313113163368507") (def ^:private consumption-mp-id "571313113163368507")
(defn- utc-start-for-date (defn- utc-start-for-date
"Compute the UTC start of a Danish date (midnight DK -> UTC)." "Compute the UTC start of a Danish date (midnight DK -> UTC)."
[^LocalDate date] [^LocalDate date]
@@ -156,7 +186,9 @@
(.toInstant) (.toInstant)
(Timestamp/from))) (Timestamp/from)))
(defn- save-time-series! [query-fn readings from]
(defn- save-time-series!
[query-fn readings from]
(doseq [r readings] (doseq [r readings]
(query-fn :insert-meter-reading! r)) (query-fn :insert-meter-reading! r))
(let [time-start (utc-start-for-date from)] (let [time-start (utc-start-for-date from)]
@@ -170,14 +202,18 @@
{:time-start time-start :hour hour :kwh (:quantity-kwh r)})))) {:time-start time-start :hour hour :kwh (:quantity-kwh r)}))))
(log/info "Saved" (count readings) "meter readings + production/consumption")) (log/info "Saved" (count readings) "meter readings + production/consumption"))
;; --- Charges --- ;; --- Charges ---
(defn- fetch-charges [access-token metering-point-ids] (defn- fetch-charges
[access-token metering-point-ids]
(api-post access-token (api-post access-token
"/api/meteringpoints/meteringpoint/getcharges" "/api/meteringpoints/meteringpoint/getcharges"
{:meteringPoints {:meteringPoint metering-point-ids}})) {:meteringPoints {:meteringPoint metering-point-ids}}))
(defn- save-charges! [query-fn charges-response]
(defn- save-charges!
[query-fn charges-response]
(let [results (:result charges-response)] (let [results (:result charges-response)]
(doseq [result results] (doseq [result results]
(let [mp-id (:id result) (let [mp-id (:id result)
@@ -240,6 +276,7 @@
:quantity (:quantity fee) :quantity (:quantity fee)
:position 0})))))) :position 0}))))))
;; --- Orchestrator --- ;; --- Orchestrator ---
(defn fetch-and-save-all! (defn fetch-and-save-all!
@@ -286,4 +323,3 @@
{:metering-points (count all-ids) {:metering-points (count all-ids)
:from (.format from date-fmt) :from (.format from date-fmt)
:to (.format to date-fmt)}))))))) :to (.format to date-fmt)})))))))
@@ -2,7 +2,9 @@
(:require (:require
[ring.util.http-response :as http-response]) [ring.util.http-response :as http-response])
(:import (:import
[java.util Date])) (java.util
Date)))
(defn healthcheck! (defn healthcheck!
[req] [req]
@@ -3,16 +3,27 @@
[clojure.data.json :as json] [clojure.data.json :as json]
[clojure.tools.logging :as log]) [clojure.tools.logging :as log])
(:import (:import
[java.net URI URLEncoder] (java.net
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] URI
[java.time LocalDate ZoneId] URLEncoder)
[java.time.format DateTimeFormatter] (java.net.http
[java.sql Timestamp])) 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 dk-zone (ZoneId/of "Europe/Copenhagen"))
(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd"))
(defn- build-url (defn- build-url
"Build the Energi Data Service API URL for day-ahead prices." "Build the Energi Data Service API URL for day-ahead prices."
[start-date end-date price-area] [start-date end-date price-area]
@@ -23,6 +34,7 @@
"&sort=TimeDK%20asc" "&sort=TimeDK%20asc"
"&limit=200")) "&limit=200"))
(defn- fetch-json (defn- fetch-json
"Fetch JSON from a URL using JDK HttpClient." "Fetch JSON from a URL using JDK HttpClient."
[url] [url]
@@ -36,6 +48,7 @@
(when (= 200 (.statusCode response)) (when (= 200 (.statusCode response))
(json/read-str (.body response) :key-fn keyword)))) (json/read-str (.body response) :key-fn keyword))))
(defn- fetch-area (defn- fetch-area
"Fetch today's and tomorrow's prices for a single price area." "Fetch today's and tomorrow's prices for a single price area."
[start end price-area] [start end price-area]
@@ -44,6 +57,7 @@
(when-let [data (fetch-json url)] (when-let [data (fetch-json url)]
(:records data)))) (:records data))))
(defn fetch-prices-from-api (defn fetch-prices-from-api
"Fetch today's and tomorrow's prices from Energi Data Service for DK1 and DK2." "Fetch today's and tomorrow's prices from Energi Data Service for DK1 and DK2."
[] []
@@ -53,6 +67,7 @@
(concat (fetch-area start end "DK1") (concat (fetch-area start end "DK1")
(fetch-area start end "DK2")))) (fetch-area start end "DK2"))))
(defn- parse-timestamp (defn- parse-timestamp
"Parse an ISO timestamp string to java.sql.Timestamp." "Parse an ISO timestamp string to java.sql.Timestamp."
[s] [s]
@@ -60,6 +75,7 @@
(Timestamp/valueOf (Timestamp/valueOf
(.replace ^String s "T" " ")))) (.replace ^String s "T" " "))))
(defn save-records! (defn save-records!
"Insert price records into the database, skipping existing ones." "Insert price records into the database, skipping existing ones."
[query-fn records] [query-fn records]
@@ -71,6 +87,7 @@
:price-dkk (:DayAheadPriceDKK rec) :price-dkk (:DayAheadPriceDKK rec)
:price-eur (:DayAheadPriceEUR rec)}))) :price-eur (:DayAheadPriceEUR rec)})))
(defn fetch-and-save! (defn fetch-and-save!
"Fetch prices from API and save to database." "Fetch prices from API and save to database."
[query-fn] [query-fn]
@@ -79,6 +96,7 @@
(save-records! query-fn records) (save-records! query-fn records)
(count records))) (count records)))
(defn get-prices-for-date (defn get-prices-for-date
"Get prices for a given LocalDate and price area from the database." "Get prices for a given LocalDate and price area from the database."
[query-fn date price-area] [query-fn date price-area]
@@ -86,10 +104,12 @@
{:date (java.sql.Date/valueOf date) {:date (java.sql.Date/valueOf date)
:price-area price-area})) :price-area price-area}))
(defn get-today-prices (defn get-today-prices
[query-fn] [query-fn]
(get-prices-for-date query-fn (LocalDate/now dk-zone) "DK1")) (get-prices-for-date query-fn (LocalDate/now dk-zone) "DK1"))
(defn get-tomorrow-prices (defn get-tomorrow-prices
[query-fn] [query-fn]
(get-prices-for-date query-fn (.plusDays (LocalDate/now dk-zone) 1) "DK1")) (get-prices-for-date query-fn (.plusDays (LocalDate/now dk-zone) 1) "DK1"))
@@ -1,22 +1,26 @@
(ns pmagnus.elprice.web.controllers.tariffs (ns pmagnus.elprice.web.controllers.tariffs
(:import (:import
[java.time LocalDateTime])) (java.time
LocalDateTime)))
(def n1-tariffs (def n1-tariffs
"N1 network tariffs in øre/kWh (incl. VAT), effective 2026-01-01. "N1 network tariffs in øre/kWh (incl. VAT), effective 2026-01-01.
Source: https://n1.dk/elnetkunder/gaeldende-priser" Source: https://n1.dk/elnetkunder/gaeldende-priser"
{:summer {:low 10.98 ;; 00:00-06:00 {:summer {:low 10.98 ; 00:00-06:00
:high 16.47 ;; 06:00-17:00, 21:00-24:00 :high 16.47 ; 06:00-17:00, 21:00-24:00
:peak 42.83} ;; 17:00-21:00 :peak 42.83} ; 17:00-21:00
:winter {:low 10.98 :winter {:low 10.98
:high 32.95 :high 32.95
:peak 98.84}}) :peak 98.84}})
(defn season (defn season
"Returns :summer (Apr-Sep) or :winter (Oct-Mar) for a given month (1-12)." "Returns :summer (Apr-Sep) or :winter (Oct-Mar) for a given month (1-12)."
[month] [month]
(if (<= 4 month 9) :summer :winter)) (if (<= 4 month 9) :summer :winter))
(defn load-level (defn load-level
"Returns :low, :high, or :peak for a given hour (0-23). "Returns :low, :high, or :peak for a given hour (0-23).
Low: 00:00-06:00 Low: 00:00-06:00
@@ -29,6 +33,7 @@
(and (<= 17 hour) (< hour 21)) :peak (and (<= 17 hour) (< hour 21)) :peak
:else :high)) :else :high))
(defn tariff-for-hour (defn tariff-for-hour
"Returns the applicable N1 tariff in øre/kWh. "Returns the applicable N1 tariff in øre/kWh.
Can be called with (month, hour) or a LocalDateTime." Can be called with (month, hour) or a LocalDateTime."
+6 -3
View File
@@ -1,10 +1,11 @@
(ns pmagnus.elprice.web.handler (ns pmagnus.elprice.web.handler
(:require (:require
[pmagnus.elprice.web.middleware.core :as middleware]
[integrant.core :as ig] [integrant.core :as ig]
[ring.util.http-response :as http-response] [pmagnus.elprice.web.middleware.core :as middleware]
[reitit.ring :as ring] [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 (defmethod ig/init-key :handler/ring
[_ {:keys [router api-path] :as opts}] [_ {:keys [router api-path] :as opts}]
@@ -30,6 +31,7 @@
(http-response/content-type "text/plain")))})) (http-response/content-type "text/plain")))}))
{:middleware [(middleware/wrap-base opts)]})) {:middleware [(middleware/wrap-base opts)]}))
(defmethod ig/init-key :router/routes (defmethod ig/init-key :router/routes
[_ {:keys [routes]}] [_ {:keys [routes]}]
(mapv (fn [route] (mapv (fn [route]
@@ -38,6 +40,7 @@
route)) route))
routes)) routes))
(defmethod ig/init-key :router/core (defmethod ig/init-key :router/core
[_ {:keys [routes env] :as opts}] [_ {:keys [routes env] :as opts}]
(if (= env :dev) (if (= env :dev)
+8 -4
View File
@@ -1,10 +1,12 @@
(ns pmagnus.elprice.web.htmx (ns pmagnus.elprice.web.htmx
(:require (:require
[ring.util.http-response :as http-response]
[hiccup.core :as h] [hiccup.core :as h]
[hiccup.page :as p])) [hiccup.page :as p]
[ring.util.http-response :as http-response]))
(defmacro page [opts & content]
(defmacro page
[opts & content]
`(-> (p/html5 ~opts `(-> (p/html5 ~opts
[:head [:head
[:meta {:charset "UTF-8"}] [:meta {:charset "UTF-8"}]
@@ -19,7 +21,9 @@
http-response/ok http-response/ok
(http-response/content-type "text/html"))) (http-response/content-type "text/html")))
(defmacro fragment [opts & content]
(defmacro fragment
[opts & content]
`(-> (str (h/html ~opts ~@content)) `(-> (str (h/html ~opts ~@content))
http-response/ok http-response/ok
(http-response/content-type "text/html"))) (http-response/content-type "text/html")))
@@ -4,11 +4,11 @@
[ring.middleware.defaults :as defaults] [ring.middleware.defaults :as defaults]
[ring.middleware.session.cookie :as cookie])) [ring.middleware.session.cookie :as cookie]))
(defn wrap-base (defn wrap-base
[{:keys [metrics site-defaults-config cookie-secret] :as opts}] [{:keys [metrics site-defaults-config cookie-secret] :as opts}]
(let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})] (let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})]
(fn [handler] (fn [handler]
(cond-> ((:middleware env/defaults) handler opts) (cond-> ((:middleware env/defaults) handler opts)
true (defaults/wrap-defaults true (defaults/wrap-defaults
(assoc-in site-defaults-config [:session :store] cookie-store)) (assoc-in site-defaults-config [:session :store] cookie-store))))))
))))
@@ -3,7 +3,9 @@
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[reitit.ring.middleware.exception :as exception])) [reitit.ring.middleware.exception :as exception]))
(defn handler [message status exception request]
(defn handler
[message status exception request]
(when (>= status 500) (when (>= status 500)
;; You can optionally use this to report error to an external service ;; You can optionally use this to report error to an external service
(log/error exception)) (log/error exception))
@@ -13,6 +15,7 @@
:data (ex-data exception) :data (ex-data exception)
:uri (:uri request)}}) :uri (:uri request)}})
(def wrap-exception (def wrap-exception
(exception/create-exception-middleware (exception/create-exception-middleware
(merge (merge
@@ -3,6 +3,7 @@
[luminus-transit.time :as time] [luminus-transit.time :as time]
[muuntaja.core :as m])) [muuntaja.core :as m]))
(def instance (def instance
(m/create (m/create
(-> m/default-options (-> m/default-options
+7 -2
View File
@@ -1,15 +1,16 @@
(ns pmagnus.elprice.web.routes.api (ns pmagnus.elprice.web.routes.api
(:require (:require
[integrant.core :as ig]
[pmagnus.elprice.web.controllers.health :as health] [pmagnus.elprice.web.controllers.health :as health]
[pmagnus.elprice.web.middleware.exception :as exception] [pmagnus.elprice.web.middleware.exception :as exception]
[pmagnus.elprice.web.middleware.formats :as formats] [pmagnus.elprice.web.middleware.formats :as formats]
[integrant.core :as ig]
[reitit.coercion.malli :as malli] [reitit.coercion.malli :as malli]
[reitit.ring.coercion :as coercion] [reitit.ring.coercion :as coercion]
[reitit.ring.middleware.muuntaja :as muuntaja] [reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters] [reitit.ring.middleware.parameters :as parameters]
[reitit.swagger :as swagger])) [reitit.swagger :as swagger]))
(def route-data (def route-data
{:coercion malli/coercion {:coercion malli/coercion
:muuntaja formats/instance :muuntaja formats/instance
@@ -31,8 +32,10 @@
;; exception handling ;; exception handling
exception/wrap-exception]}) exception/wrap-exception]})
;; Routes ;; Routes
(defn api-routes [_opts] (defn api-routes
[_opts]
[["/swagger.json" [["/swagger.json"
{:get {:no-doc true {:get {:no-doc true
:swagger {:info {:title "pmagnus.elprice API"}} :swagger {:info {:title "pmagnus.elprice API"}}
@@ -43,8 +46,10 @@
;; restarting the system ;; restarting the system
{:get #'health/healthcheck!}]]) {:get #'health/healthcheck!}]])
(derive :reitit.routes/api :reitit/routes) (derive :reitit.routes/api :reitit/routes)
(defmethod ig/init-key :reitit.routes/api (defmethod ig/init-key :reitit.routes/api
[_ {:keys [base-path] [_ {:keys [base-path]
:or {base-path ""} :or {base-path ""}
+70 -23
View File
@@ -1,39 +1,49 @@
(ns pmagnus.elprice.web.routes.ui (ns pmagnus.elprice.web.routes.ui
(:require (:require
[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.exception :as exception]
[pmagnus.elprice.web.middleware.formats :as formats] [pmagnus.elprice.web.middleware.formats :as formats]
[pmagnus.elprice.web.routes.utils :as utils] [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.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]) [reitit.ring.middleware.parameters :as parameters])
(:import (:import
[java.time LocalDateTime ZoneId] (java.time
[java.time.format DateTimeFormatter])) LocalDateTime
ZoneId)
(java.time.format
DateTimeFormatter)))
(def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) (def ^:private dk-zone (ZoneId/of "Europe/Copenhagen"))
(def ^:private hour-fmt (DateTimeFormatter/ofPattern "HH:00")) (def ^:private hour-fmt (DateTimeFormatter/ofPattern "HH:00"))
(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) (def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd"))
(defn- ->ldt [time-dk]
(defn- ->ldt
[time-dk]
(cond (cond
(instance? LocalDateTime time-dk) time-dk (instance? LocalDateTime time-dk) time-dk
(instance? java.sql.Timestamp time-dk) (instance? java.sql.Timestamp time-dk)
(.toLocalDateTime ^java.sql.Timestamp time-dk) (.toLocalDateTime ^java.sql.Timestamp time-dk)
:else (LocalDateTime/parse (str time-dk)))) :else (LocalDateTime/parse (str time-dk))))
(defn- format-hour [time-dk]
(defn- format-hour
[time-dk]
(.format (->ldt time-dk) hour-fmt)) (.format (->ldt time-dk) hour-fmt))
(defn- dkk-mwh->ore-kwh (defn- dkk-mwh->ore-kwh
"Convert DKK/MWh to øre/kWh (divide by 10)." "Convert DKK/MWh to øre/kWh (divide by 10)."
[price-dkk] [price-dkk]
(when price-dkk (when price-dkk
(/ (double price-dkk) 10.0))) (/ (double price-dkk) 10.0)))
(defn- format-ore (defn- format-ore
"Format price as total(net) where total = net + tariff, or just net if no tariff." "Format price as total(net) where total = net + tariff, or just net if no tariff."
([price-dkk] ([price-dkk]
@@ -45,31 +55,41 @@
(format "%.1f(%.1f)" (+ ore tariff) ore) (format "%.1f(%.1f)" (+ ore tariff) ore)
"-"))) "-")))
(defn- group-by-hour [prices]
(defn- group-by-hour
[prices]
(->> prices (->> prices
(group-by #(.getHour (->ldt (:time_dk %)))) (group-by #(.getHour (->ldt (:time_dk %))))
(sort-by key))) (sort-by key)))
(defn- avg-ore [quarters]
(defn- avg-ore
[quarters]
(let [vals (keep :price_dkk quarters)] (let [vals (keep :price_dkk quarters)]
(when (seq vals) (when (seq vals)
(/ (reduce + 0.0 (map double vals)) (count vals) 10.0)))) (/ (reduce + 0.0 (map double vals)) (count vals) 10.0))))
(def ^:private red-gradient (def ^:private red-gradient
["bg-red-50" "bg-red-100" "bg-red-200" "bg-red-300" ["bg-red-50" "bg-red-100" "bg-red-200" "bg-red-300"
"bg-red-400" "bg-red-500" "bg-red-600" "bg-red-700"]) "bg-red-400" "bg-red-500" "bg-red-600" "bg-red-700"])
(def ^:private green-gradient (def ^:private green-gradient
["bg-green-700" "bg-green-600" "bg-green-500" "bg-green-400" ["bg-green-700" "bg-green-600" "bg-green-500" "bg-green-400"
"bg-green-300" "bg-green-200" "bg-green-100" "bg-green-50"]) "bg-green-300" "bg-green-200" "bg-green-100" "bg-green-50"])
(defn- rank-bg [rank total]
(defn- rank-bg
[rank total]
(cond (cond
(<= rank 8) (get green-gradient (dec rank)) (<= rank 8) (get green-gradient (dec rank))
(> rank (- total 8)) (get red-gradient (- rank (- total 7))) (> rank (- total 8)) (get red-gradient (- rank (- total 7)))
:else nil)) :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) (let [sorted (sort-by #(.getMinute (->ldt (:time_dk %))) quarters)
avg (avg-ore quarters) avg (avg-ore quarters)
month (.getMonthValue (->ldt (:time_dk (first quarters)))) month (.getMonthValue (->ldt (:time_dk (first quarters))))
@@ -90,6 +110,7 @@
[:span (format "%02d:%02d" hour (.getMinute (->ldt (:time_dk q))))] [:span (format "%02d:%02d" hour (.getMinute (->ldt (:time_dk q))))]
[:span {:class "font-mono"} (format-ore (:price_dkk q) tariff)]])]])) [:span {:class "font-mono"} (format-ore (:price_dkk q) tariff)]])]]))
(defn- hour-ranks (defn- hour-ranks
"Compute a map of hour -> rank (1 = cheapest) based on total price (avg + tariff)." "Compute a map of hour -> rank (1 = cheapest) based on total price (avg + tariff)."
[grouped-hours] [grouped-hours]
@@ -103,7 +124,9 @@
(map-indexed (fn [i [hour _]] [hour (inc i)])) (map-indexed (fn [i [hour _]] [hour (inc i)]))
(into {})))) (into {}))))
(defn- price-table [prices]
(defn- price-table
[prices]
[:div [:div
(if (seq prices) (if (seq prices)
(let [grouped (group-by-hour prices) (let [grouped (group-by-hour prices)
@@ -114,16 +137,21 @@
(hour-block hour quarters (get ranks hour) total-hours))]) (hour-block hour quarters (get ranks hour) total-hours))])
[:p {:class "mt-2 text-sm text-gray-400"} "Not available yet"])]) [:p {:class "mt-2 text-sm text-gray-400"} "Not available yet"])])
(def ^:private tab-base (def ^:private tab-base
"px-4 py-2 font-medium text-sm rounded-t-lg border-2 border-b-0 ") "px-4 py-2 font-medium text-sm rounded-t-lg border-2 border-b-0 ")
(def ^:private tab-active (def ^:private tab-active
(str tab-base "border-blue-400 bg-white text-gray-900")) (str tab-base "border-blue-400 bg-white text-gray-900"))
(def ^:private tab-inactive (def ^:private tab-inactive
(str tab-base "border-gray-200 bg-gray-100 text-gray-500 hover:text-gray-700 cursor-pointer")) (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) (let [today (java.time.LocalDate/now dk-zone)
tomorrow (.plusDays today 1)] tomorrow (.plusDays today 1)]
[:div {:class "flex gap-1 mt-4"} [:div {:class "flex gap-1 mt-4"}
@@ -139,17 +167,23 @@
:hx-swap "innerHTML"} :hx-swap "innerHTML"}
(str "Tomorrow " (.format tomorrow date-fmt))])])) (str "Tomorrow " (.format tomorrow date-fmt))])]))
(defn- price-content [tab prices has-tomorrow?]
(defn- price-content
[tab prices has-tomorrow?]
[:div [:div
(tabs tab has-tomorrow?) (tabs tab has-tomorrow?)
[:div {:id "price-content" [:div {:id "price-content"
:class "border-2 border-blue-400 rounded-b-lg rounded-tr-lg p-4 bg-white"} :class "border-2 border-blue-400 rounded-b-lg rounded-tr-lg p-4 bg-white"}
(price-table prices)]]) (price-table prices)]])
(defn- has-tomorrow? [query-fn]
(defn- has-tomorrow?
[query-fn]
(seq (prices/get-tomorrow-prices 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) (prices/fetch-and-save! query-fn)
(page {:lang "en"} (page {:lang "en"}
[:h1 {:class "text-2xl font-bold text-gray-900 cursor-pointer hover:text-blue-600 transition-colors" [:h1 {:class "text-2xl font-bold text-gray-900 cursor-pointer hover:text-blue-600 transition-colors"
@@ -162,20 +196,28 @@
[:div {:id "price-panel"} [:div {:id "price-panel"}
(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-today [query-fn request]
(defn prices-today
[query-fn request]
(fragment (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 (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) (prices/fetch-and-save! query-fn)
(fragment (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]) (let [date-str (get-in request [:params :date])
date (if date-str date (if date-str
(java.time.LocalDate/parse date-str) (java.time.LocalDate/parse date-str)
@@ -194,14 +236,17 @@
(str "Synced " (:metering-points result) (str "Synced " (:metering-points result)
" metering points (" (:from result) " to " (:to result) ")")])))) " metering points (" (:from result) " to " (:to result) ")")]))))
;; Routes ;; Routes
(defn ui-routes [{:keys [query-fn]}] (defn ui-routes
[{:keys [query-fn]}]
[["/" {:get (partial home query-fn)}] [["/" {:get (partial home query-fn)}]
["/prices/today" {:get (partial prices-today query-fn)}] ["/prices/today" {:get (partial prices-today query-fn)}]
["/prices/tomorrow" {:get (partial prices-tomorrow query-fn)}] ["/prices/tomorrow" {:get (partial prices-tomorrow query-fn)}]
["/prices/fetch" {:post (partial fetch-prices query-fn)}] ["/prices/fetch" {:post (partial fetch-prices query-fn)}]
["/eloverblik/sync" {:post (partial sync-eloverblik query-fn)}]]) ["/eloverblik/sync" {:post (partial sync-eloverblik query-fn)}]])
(def route-data (def route-data
{:muuntaja formats/instance {:muuntaja formats/instance
:middleware :middleware
@@ -213,8 +258,10 @@
;; exception handling ;; exception handling
exception/wrap-exception]}) exception/wrap-exception]})
(derive :reitit.routes/ui :reitit/routes) (derive :reitit.routes/ui :reitit/routes)
(defmethod ig/init-key :reitit.routes/ui (defmethod ig/init-key :reitit.routes/ui
[_ {:keys [base-path] [_ {:keys [base-path]
:or {base-path ""} :or {base-path ""}
@@ -2,10 +2,12 @@
(def route-data-path [:reitit.core/match :data]) (def route-data-path [:reitit.core/match :data])
(defn route-data (defn route-data
[req] [req]
(get-in req route-data-path)) (get-in req route-data-path))
(defn route-data-key (defn route-data-key
[req k] [req k]
(get-in req (conj route-data-path k))) (get-in req (conj route-data-path k)))