Compare commits

..
28 Commits
Author SHA1 Message Date
magnusandClaude Opus 4.6 3a319f5cf4 Fix sync: add missing tables, Linux date, token from env
Add production and consumption table migrations. Fix sync-month.sh
to use Linux date syntax and default to port 3030. Read Eloverblik
token from ELOVERBLIK_TOKEN env var with .token file fallback.
Mount .token into Docker container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:06:14 +01:00
magnusandClaude Opus 4.6 14b583ccb9 Add multi-stage Dockerfile, docker-compose, and fix insert conflict
Rebuild Dockerfile with 3 stages (CSS, uberjar, runtime) on port 3030.
Add docker-compose.yml loading .env. Fix insert-price! ON CONFLICT to
target (time_dk, price_area) matching the actual unique index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:44:50 +01:00
magnusandClaude Opus 4.6 4984c1b207 Replace local Docker PostgreSQL with external DB via .env
Remove docker-compose.yml and use JDBC_URL from .env for all
profiles. Makefile now exports .env variables automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:25:04 +01:00
magnusandClaude Opus 4.6 139c028607 Format codebase with cljstyle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:59:18 +01:00
magnusandClaude Opus 4.6 d0ff9b485d Skip sync for existing data and too-recent dates
- Check consumption table before fetching; return early if data exists
- Return "no data to fetch" for dates >= yesterday (not yet available)
- Add sync-month.sh script to sync all days in a month
- Add kill-server.sh script to free port 3000

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:50:41 +01:00
magnusandClaude Opus 4.6 d756c98810 Add date-parameterized sync with production and consumption tables
- Accept ?date= param on POST /eloverblik/sync (defaults to today)
- Populate production table from D06 meter (571313113163368491)
- Populate consumption table from D07 meter (571313113163368507)
- ON CONFLICT DO NOTHING to skip existing data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:09:36 +01:00
magnusandClaude Opus 4.6 ca473c62bb Add Eloverblik data pipeline: fetch metering points, readings, and charges to PostgreSQL
- Add eloverblik controller with API client for metering points, time series, and charges
- Add migrations for metering_points, meter_readings, and charges tables
- Add HugSQL queries (upsert-metering-point!, insert-meter-reading!, insert-charge!)
- Add POST /eloverblik/sync route to trigger sync from browser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:25:59 +01:00
magnus c54ecd2cfa Add .token 2026-02-14 15:06:55 +01:00
magnusandClaude Opus 4.6 feef551fc6 Add green gradient background for the 8 cheapest hours
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:45:43 +01:00
magnusandClaude Opus 4.6 9844803882 Add red gradient background for the 8 most expensive hours
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:40:20 +01:00
magnusandClaude Opus 4.6 83d9f22bbd Add hourly price rank (#1 = cheapest) based on total price
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:35:39 +01:00
magnusandClaude Opus 4.6 2000154c8a Display prices as total(spot) including N1 network tariff
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:26:07 +01:00
magnusandClaude Opus 4.6 d4fd3a7512 Add N1 network tariff data with season/hour-based lookup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:22:48 +01:00
magnusandClaude Opus 4.6 4d8d692dfc Hide Tomorrow tab when no data is available
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:15:32 +01:00
magnusandClaude Opus 4.6 99780ebf60 Show date on Today/Tomorrow tabs and remove table headings
Tabs now display the date (e.g. "Today 2026-02-14"). Removed redundant
"Today — DK1" / "Tomorrow — DK1" headings from the price tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:10:25 +01:00
magnusandClaude Opus 4.6 4ef693168c Fetch prices on page load and refresh on headline click
Remove the Refresh Prices button. Prices are fetched automatically on
page load, and clicking the headline triggers a re-fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:04:49 +01:00
magnusandClaude Opus 4.6 6b95a95ca6 Highlight active tab when switching between Today/Tomorrow
Swap the full tab panel on click so the active tab styling updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:01:34 +01:00
magnusandClaude Opus 4.6 bca9da4639 Add tailwind target and auto-start system in make run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:56:55 +01:00
magnusandClaude Opus 4.6 9e78275c83 Add Today/Tomorrow tabs for price display
HTMX-powered tabs switch between today's and tomorrow's prices without
full page reload. Today tab is active by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:42:52 +01:00
magnusandClaude Opus 4.6 4fc2f82fbb Split API fetch into separate requests per price area
One request per area (DK1, DK2) avoids hitting the 200-record limit
that was truncating tomorrow's data when both areas were fetched together.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:38:33 +01:00
magnusandClaude Opus 4.6 97a95684cf Skip existing prices on insert and add unique index on (time_dk, price_area)
Changed upsert to INSERT ON CONFLICT DO NOTHING so existing prices are
never overwritten. Added unique index on (time_dk, price_area) to enforce
no duplicate quarter-hour entries per area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:14:28 +01:00
magnusandClaude Opus 4.6 9554e511ad Collapse quarter-hour details by default
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:57:24 +01:00
magnusandClaude Opus 4.6 7e7b28cf85 Group prices by hour with expandable quarter details and blue borders
Each hour shows as an open-by-default card with the average price and
indented quarter-hour rows. Added @source directive to Tailwind v4 input
so .clj files are scanned for utility classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:51:07 +01:00
magnusandClaude Opus 4.6 c5ccada192 Fix URL encoding of JSON filter parameter in API request
URI/create rejects raw braces/brackets in query strings. URL-encode the
filter JSON so the request succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:32:44 +01:00
magnusandClaude Opus 4.6 1b600ad8d4 Fetch and display day-ahead electricity spot prices from Energi Data Service
Adds a prices controller that fetches DK1/DK2 hourly spot prices via the
Energi Data Service API, stores them in PostgreSQL, and displays today's
and tomorrow's DK1 prices on the home page in øre/kWh. An HTMX "Refresh
Prices" button triggers the fetch-and-display cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:23:40 +01:00
magnusandClaude Opus 4.6 07b442a579 Add CLAUDE.md with project architecture and development commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:03:38 +01:00
magnusandClaude Opus 4.6 c3b2efd68c Remove unused kit-mysql require from core.clj
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:50:12 +01:00
magnusandClaude Opus 4.6 c1f386ef44 Initial Kit project with PostgreSQL, HTMX, Hiccup, and Tailwind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:45:09 +01:00
57 changed files with 2932 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
kit.git-config.edn
.DS_Store
.nrepl-port
hs_err_pid*.log
.cpcache/
target
log/
.shadow-cljs/
node_modules/
modules/
resources/public/css/output.css
# IntelliJ
*.iml
.idea/
*.bak
# clj-kondo
.clj-kondo/.cache/
.calva/output-window/
.lsp/.cache/
.token
.env
+92
View File
@@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Development Commands
```bash
# Development
make run # Start dev server (port 3000)
make repl # Start nREPL
npm run css:watch # Watch/rebuild Tailwind CSS
# Testing
make test # Run all tests
clj -M:test # Alternative
# Build
make uberjar # Build production JAR (target/elprice-standalone.jar)
make clean # Remove target/
# Formatting
bb format # Format source with cljstyle
# Docker
docker compose up -d # Start PostgreSQL (port 5432)
```
### REPL Commands
```clojure
(dev-prep!) (go) ;; Start system in REPL
(reset) ;; Reload code and restart system
(halt) ;; Stop system
(migrate) ;; Run database migrations
(rollback) ;; Rollback last migration
(reset-db) ;; Drop and re-migrate database
;; Testing in REPL:
(test-prep!) (go) (run-tests)
```
## Architecture
**Kit framework** web application: Clojure, PostgreSQL, HTMX, Hiccup, Tailwind CSS.
### System Initialization
Integrant manages the component lifecycle. Configuration is in `resources/system.edn`, which defines components for the HTTP server (Undertow), database connection pool, migrations, routes, and middleware. The entry point is `pmagnus.elprice.core`, which loads the system config via `pmagnus.elprice.config`.
### Routing
Two separate route groups registered as Integrant components:
- **API routes** (`web/routes/api.clj`) — mounted at `/api`, returns JSON, has Swagger docs at `/api/swagger.json`
- **UI routes** (`web/routes/ui.clj`) — mounted at `/`, returns HTML via HTMX + Hiccup
Routes are combined in `web/handler.clj` which builds the Ring handler with the full middleware stack.
### Frontend Pattern
Server-side rendered HTML using Hiccup data structures. Interactive behavior via HTMX (no client-side JS framework). Two macros in `web/htmx.clj`:
- `page` — renders a full HTML5 document (includes Tailwind CSS and HTMX script tags)
- `fragment` — renders an HTML fragment for HTMX partial responses
Tailwind CSS v4 scans `.clj` and `.html` files for utility classes. Source: `resources/css/input.css` → Output: `resources/public/css/output.css`.
### Database
PostgreSQL via kit-postgres and conman (connection pooling). SQL queries defined with HugSQL in `resources/queries.sql`. Migrations via Migratus in `resources/migrations/`. Migrations run automatically on system startup (`migrate-on-init? true`).
- Dev/Test DB: `jdbc:postgresql://localhost:5432/elprice?user=elprice&password=elprice`
- Prod DB: via `JDBC_URL` environment variable
### Middleware
Defined in `web/middleware/core.clj`. Cookie-based sessions (http-only, same-site strict). CSRF is disabled. Static assets served from `resources/public/`.
### Source Layout
```
src/clj/pmagnus/elprice/
├── core.clj # App entry point, system lifecycle
├── config.clj # Integrant config loader
└── web/
├── handler.clj # Ring handler + route composition
├── htmx.clj # page/fragment Hiccup macros
├── controllers/ # Request handlers (business logic)
├── middleware/ # Ring middleware (core, exception, formats)
└── routes/ # Reitit route definitions (api, ui)
```
Environment-specific code lives in `env/{dev,prod,test}/`. Dev REPL utilities are in `env/dev/clj/pmagnus/elprice/user.clj`.
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS css
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/css resources/css
COPY src src
RUN npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --minify
FROM clojure:temurin-21-tools-deps-alpine AS build
WORKDIR /app
COPY deps.edn build.clj ./
RUN clojure -P && clojure -P -T:build
COPY . .
COPY --from=css /app/resources/public/css/output.css resources/public/css/output.css
RUN clojure -T:build all
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/elprice-standalone.jar app.jar
EXPOSE 3030
ENV PORT=3030
ENTRYPOINT ["java", "-jar", "app.jar"]
+20
View File
@@ -0,0 +1,20 @@
include .env
export
clean:
rm -rf target
run:
clj -M:dev -e "(dev-prep!) (go)" -r
repl:
clj -M:dev:nrepl
tailwind:
npm run css:watch
test:
clj -M:test
uberjar:
clj -T:build all
+35
View File
@@ -1,2 +1,37 @@
# elprice # elprice
Start a [REPL](#repls) in your editor or terminal of choice.
Start the server with:
```clojure
(go)
```
The default API is available under http://localhost:3000/api
System configuration is available under `resources/system.edn`.
To reload changes:
```clojure
(reset)
```
## REPLs
### Cursive
Configure a [REPL following the Cursive documentation](https://cursive-ide.com/userguide/repl.html). Using the default "Run with IntelliJ project classpath" option will let you select an alias from the ["Clojure deps" aliases selection](https://cursive-ide.com/userguide/deps.html#refreshing-deps-dependencies).
### CIDER
Use the `cider` alias for CIDER nREPL support (run `clj -M:dev:cider`). See the [CIDER docs](https://docs.cider.mx/cider/basics/up_and_running.html) for more help.
Note that this alias runs nREPL during development. To run nREPL in production (typically when the system starts), use the kit-nrepl library through the +nrepl profile as described in [the documentation](https://kit-clj.github.io/docs/profiles.html#profiles).
### Command Line
Run `clj -M:dev:nrepl` or `make repl`.
Note that, just like with [CIDER](#cider), this alias runs nREPL during development. To run nREPL in production (typically when the system starts), use the kit-nrepl library through the +nrepl profile as described in [the documentation](https://kit-clj.github.io/docs/profiles.html#profiles).
+27
View File
@@ -0,0 +1,27 @@
{:min-bb-version "0.8.156"
:deps {failjure/failjure {:mvn/version "2.3.0"}}
:tasks {:requires ([babashka.fs :as fs]
[babashka.tasks :refer [shell]])
run {:doc "starts the app"
:task (if (fs/windows?)
(clojure {:dir "."} "-M:dev")
(shell {:dir "."} "clj -M:dev"))}
nrepl {:doc "starts the nREPL"
:task (clojure {:dir "."} "-M:dev:nrepl")}
cider {:doc "starts the cider"
:task (clojure {:dir "."} "-M:dev:cider")}
test {:doc "runs tests"
:task (clojure {:dir "."} "-M:test")}
uberjar {:doc "builds the uberjar"
:task (clojure {:dir "."} "-T:build all")}
format {:doc "Formats codebase"
:task (try
(shell {:dir "src"} "cljstyle fix")
(catch Exception _
(clojure {:dir "src"} "-M:dev -m cljstyle.main fix")))}}}
+41
View File
@@ -0,0 +1,41 @@
(ns build
(:require [clojure.string :as string]
[clojure.tools.build.api :as b]))
(def lib 'pmagnus/elprice)
(def main-cls (string/join "." (filter some? [(namespace lib) (name lib) "core"])))
(def version (format "0.0.1-SNAPSHOT"))
(def target-dir "target")
(def class-dir (str target-dir "/" "classes"))
(def uber-file (format "%s/%s-standalone.jar" target-dir (name lib)))
(def basis (b/create-basis {:project "deps.edn"}))
(defn clean
"Delete the build target directory"
[_]
(println (str "Cleaning " target-dir))
(b/delete {:path target-dir}))
(defn prep [_]
(println "Writing Pom...")
(b/write-pom {:class-dir class-dir
:lib lib
:version version
:basis basis
:src-dirs ["src/clj"]})
(b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"]
:target-dir class-dir}))
(defn uber [_]
(println "Compiling Clojure...")
(b/compile-clj {:basis basis
:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"]
:class-dir class-dir})
(println "Making uberjar...")
(b/uber {:class-dir class-dir
:uber-file uber-file
:main main-cls
:basis basis}))
(defn all [_]
(do (clean nil) (prep nil) (uber nil)))
+70
View File
@@ -0,0 +1,70 @@
{:paths ["src/clj"
"resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
;; Routing
metosin/reitit {:mvn/version "0.9.2"}
;; Ring
metosin/ring-http-response {:mvn/version "0.9.5"}
ring/ring-core {:mvn/version "1.15.3"}
ring/ring-defaults {:mvn/version "0.7.0"}
;; Logging
ch.qos.logback/logback-classic {:mvn/version "1.5.20"}
;; Data coercion
luminus-transit/luminus-transit {:mvn/version "0.1.6"
:exclusions [com.cognitect/transit-clj]}
metosin/muuntaja {:mvn/version "0.6.11"}
;; kit Libs
io.github.kit-clj/kit-core {:mvn/version "1.0.9"}
io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"}
io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"}
io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"}
io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"}
hiccup/hiccup {:mvn/version "2.0.0"}
org.clojure/data.json {:mvn/version "2.5.1"}}
:aliases {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.11"}}
:ns-default build}
:dev {:extra-deps {com.lambdaisland/classpath {:mvn/version "0.6.58"}
criterium/criterium {:mvn/version "0.4.6"}
expound/expound {:mvn/version "0.9.0"}
integrant/repl {:mvn/version "0.5.0"}
mvxcvi/cljstyle {:mvn/version "0.17.642"}
pjstadig/humane-test-output {:mvn/version "0.11.0"}
ring/ring-devel {:mvn/version "1.15.3"}
ring/ring-mock {:mvn/version "0.6.2"}
io.github.kit-clj/kit-generator {:mvn/version "0.2.5"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
}
:extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"]}
:nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"}}
:main-opts ["-m" "nrepl.cmdline" "-i"]}
:cider {:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"}
cider/cider-nrepl {:mvn/version "0.58.0"}}
:main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]" "-i"]}
:test {:extra-deps {criterium/criterium {:mvn/version "0.4.6"}
expound/expound {:mvn/version "0.9.0"}
integrant/repl {:mvn/version "0.5.0"}
io.github.cognitect-labs/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
:git/tag "v0.5.1"
:git/sha "dfb30dd"}
pjstadig/humane-test-output {:mvn/version "0.11.0"}
ring/ring-devel {:mvn/version "1.15.3"}
ring/ring-mock {:mvn/version "0.6.2"}
io.github.kit-clj/kit-generator {:mvn/version "0.2.5"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
peridot/peridot {:mvn/version "0.5.4"}
org.clj-commons/byte-streams {:mvn/version "0.3.4"}
com.lambdaisland/classpath {:mvn/version "0.6.58"}}
:exec-fn cognitect.test-runner.api/test
:extra-paths ["env/dev/clj" "env/dev/resources" "env/test/resources" "test/clj"]
:main-opts ["-e" "(require 'pjstadig.humane-test-output) (pjstadig.humane-test-output/activate!)"
"-m" "cognitect.test-runner"]}}
}
+10
View File
@@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "3030:3030"
env_file:
- .env
volumes:
- ./.token:/app/.token:ro
restart: unless-stopped
+5
View File
@@ -0,0 +1,5 @@
(ns pmagnus.elprice.dev-middleware)
(defn wrap-dev [handler _opts]
(-> handler
))
+14
View File
@@ -0,0 +1,14 @@
(ns pmagnus.elprice.env
(:require
[clojure.tools.logging :as log]
[pmagnus.elprice.dev-middleware :refer [wrap-dev]]))
(def defaults
{:init (fn []
(log/info "\n-=[elprice starting using the development or test profile]=-"))
:start (fn []
(log/info "\n-=[elprice started successfully using the development or test profile]=-"))
:stop (fn []
(log/info "\n-=[elprice has shut down successfully]=-"))
:middleware wrap-dev
:opts {:profile :dev}})
+59
View File
@@ -0,0 +1,59 @@
(ns user
"Userspace functions you can run by default in your local REPL."
(:require
[clojure.pprint]
[clojure.spec.alpha :as s]
[clojure.tools.namespace.repl :as repl]
[criterium.core :as c] ;; benchmarking
[expound.alpha :as expound]
[integrant.core :as ig]
[integrant.repl :refer [clear go halt prep init reset reset-all]]
[integrant.repl.state :as state]
[kit.api :as kit]
[lambdaisland.classpath.watch-deps :as watch-deps] ;; hot loading for deps
[pmagnus.elprice.core :refer [start-app]]))
;; uncomment to enable hot loading for deps
(watch-deps/start! {:aliases [:dev :test]})
(alter-var-root #'s/*explain-out* (constantly expound/printer))
(add-tap (bound-fn* clojure.pprint/pprint))
(defn dev-prep!
[]
(integrant.repl/set-prep! (fn []
(-> (pmagnus.elprice.config/system-config {:profile :dev})
(ig/expand)))))
(defn test-prep!
[]
(integrant.repl/set-prep! (fn []
(-> (pmagnus.elprice.config/system-config {:profile :test})
(ig/expand)))))
;; Can change this to test-prep! if want to run tests as the test profile in your repl
;; You can run tests in the dev profile, too, but there are some differences between
;; the two profiles.
(dev-prep!)
(repl/set-refresh-dirs "src/clj")
(def refresh repl/refresh)
(defn reset-db []
(migratus.core/reset (:db.sql/migrations state/system)))
(defn rollback []
(migratus.core/rollback (:db.sql/migrations state/system)))
(defn migrate []
(migratus.core/migrate (:db.sql/migrations state/system)))
(def query-fn (:db.sql/query-fn state/system))
(comment
(go)
(reset))
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>log/pmagnus.elprice.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/pmagnus.elprice.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- keep 30 days of history -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
</encoder>
</appender>
<logger name="org.eclipse.aether" level="warn" />
<logger name="io.methvin.watcher" level="warn" />
<logger name="org.eclipse.jgit" level="warn" />
<logger name="com.zaxxer.hikari" level="warn" />
<logger name="org.apache.http" level="warn" />
<logger name="org.xnio.nio" level="warn" />
<logger name="io.undertow" level="warn" />
<root level="DEBUG">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>
+12
View File
@@ -0,0 +1,12 @@
(ns pmagnus.elprice.env
(:require [clojure.tools.logging :as log]))
(def defaults
{:init (fn []
(log/info "\n-=[elprice starting]=-"))
:start (fn []
(log/info "\n-=[elprice started successfully]=-"))
:stop (fn []
(log/info "\n-=[elprice has shut down successfully]=-"))
:middleware (fn [handler _] handler)
:opts {:profile :prod}})
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-5relative %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.zaxxer.hikari" level="error" />
<logger name="org.apache.http" level="error" />
<logger name="org.xnio.nio" level="error" />
<logger name="io.undertow" level="error" />
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-5relative %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.zaxxer.hikari" level="error" />
<logger name="org.apache.http" level="error" />
<logger name="org.xnio.nio" level="error" />
<logger name="io.undertow" level="error" />
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Kill any process listening on port 3000.
# Usage: ./kill-server.sh
set -euo pipefail
pids=$(lsof -ti:3000 2>/dev/null || true)
if [ -z "$pids" ]; then
echo "No process on port 3000"
else
echo "$pids" | xargs kill
echo "Killed: $pids"
fi
+8
View File
@@ -0,0 +1,8 @@
{:full-name "pmagnus/elprice"
:ns-name "pmagnus.elprice"
:sanitized "pmagnus/elprice"
:name "elprice"
:modules {:root "modules"
:repositories [{:url "https://github.com/kit-clj/modules.git"
:tag "master"
:name "kit-modules"}]}}
+1055
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "elprice",
"version": "1.0.0",
"description": "Start a [REPL](#repls) in your editor or terminal of choice.",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"css:build": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --minify",
"css:watch": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@tailwindcss/cli": "^4.1.18",
"tailwindcss": "^4.1.18"
}
}
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@source "../../src/**/*.clj";
@@ -0,0 +1 @@
DROP TABLE IF EXISTS day_ahead_prices;
@@ -0,0 +1,8 @@
CREATE TABLE day_ahead_prices (
time_utc TIMESTAMPTZ NOT NULL,
time_dk TIMESTAMP NOT NULL,
price_area VARCHAR(3) NOT NULL,
price_dkk NUMERIC(10,2),
price_eur NUMERIC(10,6),
PRIMARY KEY (time_utc, price_area)
);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_day_ahead_prices_time_dk_area;
@@ -0,0 +1 @@
CREATE UNIQUE INDEX idx_day_ahead_prices_time_dk_area ON day_ahead_prices (time_dk, price_area);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS metering_points;
@@ -0,0 +1,15 @@
CREATE TABLE metering_points (
metering_point_id VARCHAR(18) PRIMARY KEY,
type_of_mp VARCHAR(50),
balance_supplier VARCHAR(100),
street_name VARCHAR(200),
building_number VARCHAR(20),
postcode VARCHAR(10),
city_name VARCHAR(100),
has_relation BOOLEAN,
settlement_method VARCHAR(50),
reading_occurrence VARCHAR(50),
consumer_start_date DATE,
first_consumer_name VARCHAR(200),
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS meter_readings;
@@ -0,0 +1,9 @@
CREATE TABLE meter_readings (
metering_point_id VARCHAR(18) NOT NULL REFERENCES metering_points(metering_point_id),
time_dk TIMESTAMP NOT NULL,
quantity_kwh NUMERIC(10,3),
quality VARCHAR(50),
PRIMARY KEY (metering_point_id, time_dk)
);
--;;
CREATE INDEX idx_meter_readings_time_dk ON meter_readings(time_dk);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS charges;
@@ -0,0 +1,16 @@
CREATE TABLE charges (
id SERIAL,
metering_point_id VARCHAR(18) NOT NULL REFERENCES metering_points(metering_point_id),
charge_type VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
description VARCHAR(500),
owner VARCHAR(100) NOT NULL,
valid_from_date DATE NOT NULL,
valid_to_date DATE,
period_type VARCHAR(50),
price NUMERIC(12,6),
quantity INTEGER,
position INTEGER NOT NULL DEFAULT 0,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (metering_point_id, charge_type, name, owner, valid_from_date, position)
);
@@ -0,0 +1 @@
DROP TABLE production;
@@ -0,0 +1,6 @@
CREATE TABLE production (
time_start TIMESTAMPTZ NOT NULL,
hour INTEGER NOT NULL,
kwh DOUBLE PRECISION,
PRIMARY KEY (time_start, hour)
);
@@ -0,0 +1 @@
DROP TABLE consumption;
@@ -0,0 +1,6 @@
CREATE TABLE consumption (
time_start TIMESTAMPTZ NOT NULL,
hour INTEGER NOT NULL,
kwh DOUBLE PRECISION,
PRIMARY KEY (time_start, hour)
);
+1
View File
@@ -0,0 +1 @@
See https://github.com/yogthos/migratus
+67
View File
@@ -0,0 +1,67 @@
-- :name insert-price! :! :n
-- :doc Insert a day-ahead price record, skip if already exists
INSERT INTO day_ahead_prices (time_utc, time_dk, price_area, price_dkk, price_eur)
VALUES (:time-utc, :time-dk, :price-area, :price-dkk, :price-eur)
ON CONFLICT (time_dk, price_area) DO NOTHING;
-- :name get-prices-for-date :? :*
-- :doc Get all prices for a given date (time_dk local date) and price area
SELECT time_utc, time_dk, price_area, price_dkk, price_eur
FROM day_ahead_prices
WHERE time_dk::date = :date
AND price_area = :price-area
ORDER BY time_dk;
-- :name upsert-metering-point! :! :n
-- :doc Upsert a metering point, refreshing data on re-sync
INSERT INTO metering_points (metering_point_id, type_of_mp, balance_supplier, street_name,
building_number, postcode, city_name, has_relation, settlement_method,
reading_occurrence, consumer_start_date, first_consumer_name)
VALUES (:metering-point-id, :type-of-mp, :balance-supplier, :street-name,
:building-number, :postcode, :city-name, :has-relation, :settlement-method,
:reading-occurrence, :consumer-start-date, :first-consumer-name)
ON CONFLICT (metering_point_id) DO UPDATE SET
type_of_mp = EXCLUDED.type_of_mp,
balance_supplier = EXCLUDED.balance_supplier,
street_name = EXCLUDED.street_name,
building_number = EXCLUDED.building_number,
postcode = EXCLUDED.postcode,
city_name = EXCLUDED.city_name,
has_relation = EXCLUDED.has_relation,
settlement_method = EXCLUDED.settlement_method,
reading_occurrence = EXCLUDED.reading_occurrence,
consumer_start_date = EXCLUDED.consumer_start_date,
first_consumer_name = EXCLUDED.first_consumer_name,
fetched_at = now();
-- :name insert-meter-reading! :! :n
-- :doc Insert a meter reading, skip if already exists
INSERT INTO meter_readings (metering_point_id, time_dk, quantity_kwh, quality)
VALUES (:metering-point-id, :time-dk, :quantity-kwh, :quality)
ON CONFLICT (metering_point_id, time_dk) DO NOTHING;
-- :name insert-charge! :! :n
-- :doc Insert a charge record, skip if already exists
INSERT INTO charges (metering_point_id, charge_type, name, description, owner,
valid_from_date, valid_to_date, period_type, price, quantity, position)
VALUES (:metering-point-id, :charge-type, :name, :description, :owner,
:valid-from-date, :valid-to-date, :period-type, :price, :quantity, :position)
ON CONFLICT (metering_point_id, charge_type, name, owner, valid_from_date, position) DO NOTHING;
-- :name count-consumption-for-date :? :1
-- :doc Count consumption records for a given UTC time_start
SELECT count(*) AS cnt
FROM consumption
WHERE time_start = :time-start;
-- :name insert-production! :! :n
-- :doc Insert a production record, skip if already exists
INSERT INTO production (time_start, hour, kwh)
VALUES (:time-start, :hour, :kwh)
ON CONFLICT (time_start, hour) DO NOTHING;
-- :name insert-consumption! :! :n
-- :doc Insert a consumption record, skip if already exists
INSERT INTO consumption (time_start, hour, kwh)
VALUES (:time-start, :hour, :kwh)
ON CONFLICT (time_start, hour) DO NOTHING;
+70
View File
@@ -0,0 +1,70 @@
{:system/env
#profile {:dev :dev
:test :test
:prod :prod}
:server/http
{:port #long #or [#env PORT 3000]
:host #or [#env HTTP_HOST "0.0.0.0"]
:handler #ig/ref :handler/ring}
:handler/ring
{:router #ig/ref :router/core
:api-path "/api"
:cookie-secret #or [#env COOKIE_SECRET "JJCILTOQLMYRBFRU"]
;; from ring.middleware.defaults. anti-forgery `false` by default because services may not require it
:site-defaults-config {:params {:urlencoded true
:multipart true
:nested true
:keywordize true}
:cookies true
:session {:flash true
:cookie-name "pmagnus.elprice"
:cookie-attrs {:max-age 86400
:http-only true
:same-site :strict}}
:security {:anti-forgery false
:xss-protection {:enable? true,
:mode :block}
:frame-options :sameorigin
:content-type-options :nosniff}
:static {:resources "public"}
:responses {:not-modified-responses true
:absolute-redirects true
:content-types true
:default-charset "utf-8"}}}
:reitit.routes/api
{:base-path "/api"
:env #ig/ref :system/env
:query-fn #ig/ref :db.sql/query-fn}
:router/routes
{:routes #ig/refset :reitit/routes}
:router/core
{:routes #ig/ref :router/routes
:env #ig/ref :system/env}
:db.sql/connection
#profile {:dev {:jdbc-url #env JDBC_URL}
:test {:jdbc-url #env JDBC_URL}
:prod {:jdbc-url #env JDBC_URL
:init-size 1
:min-idle 1
:max-idle 8
:max-active 32}}
:db.sql/query-fn
{:conn #ig/ref :db.sql/connection
:options {}
:filename "queries.sql"
:env #ig/ref :system/env}
:db.sql/migrations
{:store :database
:db {:datasource #ig/ref :db.sql/connection}
:migrate-on-init? true}
:reitit.routes/ui {:base-path "",
:env #ig/ref :system/env
:query-fn #ig/ref :db.sql/query-fn}}
+11
View File
@@ -0,0 +1,11 @@
(ns pmagnus.elprice.config
(:require
[kit.config :as config]))
(def ^:const system-filename "system.edn")
(defn system-config
[options]
(config/read-config system-filename options))
+48
View File
@@ -0,0 +1,48 @@
(ns pmagnus.elprice.core
(:gen-class)
(:require
[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.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]))
;; log uncaught exceptions in threads
(Thread/setDefaultUncaughtExceptionHandler
(fn [thread ex]
(log/error {:what :uncaught-exception
:exception ex
:where (str "Uncaught exception on" (.getName thread))})))
(defonce system (atom nil))
(defn stop-app
[]
((or (:stop defaults) (fn [])))
(some-> (deref system) (ig/halt!)))
(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
[& _]
(start-app)
(.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (stop-app) (shutdown-agents)))))
@@ -0,0 +1,326 @@
(ns pmagnus.elprice.web.controllers.eloverblik
(:require
[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.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
[]
(or (System/getenv "ELOVERBLIK_TOKEN")
(str/trim (slurp ".token"))))
(defn- get-access-token
"Exchange refresh token for a short-lived access token."
[refresh-token]
(let [client (HttpClient/newHttpClient)
request (-> (HttpRequest/newBuilder)
(.uri (URI/create (str base-url "/api/token")))
(.header "Authorization" (str "Bearer " refresh-token))
(.header "Accept" "application/json")
(.GET)
(.build))
response (.send client request (HttpResponse$BodyHandlers/ofString))]
(when (= 200 (.statusCode response))
(let [body (json/read-str (.body response) :key-fn keyword)]
(:result body)))))
;; --- HTTP helpers ---
(defn- api-get
"GET with Bearer access token, returns parsed JSON."
[access-token path]
(let [client (HttpClient/newHttpClient)
request (-> (HttpRequest/newBuilder)
(.uri (URI/create (str base-url path)))
(.header "Authorization" (str "Bearer " access-token))
(.header "Accept" "application/json")
(.GET)
(.build))
response (.send client request (HttpResponse$BodyHandlers/ofString))]
(log/info "GET" path "->" (.statusCode response))
(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]
(let [client (HttpClient/newHttpClient)
json-str (json/write-str body)
request (-> (HttpRequest/newBuilder)
(.uri (URI/create (str base-url path)))
(.header "Authorization" (str "Bearer " access-token))
(.header "Content-Type" "application/json")
(.header "Accept" "application/json")
(.POST (HttpRequest$BodyPublishers/ofString json-str))
(.build))
response (.send client request (HttpResponse$BodyHandlers/ofString))]
(log/info "POST" path "->" (.statusCode response))
(when (= 200 (.statusCode response))
(json/read-str (.body response) :key-fn keyword))))
;; --- Metering Points ---
(defn- fetch-metering-points
[access-token]
(let [data (api-get access-token "/api/meteringpoints/meteringpoints?includeAll=true")]
(:result data)))
(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]
(query-fn :upsert-metering-point!
{:metering-point-id (:meteringPointId mp)
:type-of-mp (:typeOfMP mp)
:balance-supplier (:balanceSupplierName mp)
:street-name (:streetName mp)
:building-number (:buildingNumber mp)
:postcode (:postcode mp)
:city-name (:cityName mp)
:has-relation (:hasRelation mp)
:settlement-method (:settlementMethod mp)
:reading-occurrence (:meterReadingOccurrence mp)
:consumer-start-date (parse-date (:consumerStartDate mp))
:first-consumer-name (:firstConsumerPartyName mp)}))
(defn- save-metering-points!
[query-fn metering-points]
(let [all-mps (mapcat (fn [mp]
(cons mp (:childMeteringPoints mp)))
metering-points)]
(doseq [mp all-mps]
(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]
(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)))
(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]
(-> date
(.atStartOfDay dk-zone)
(.toInstant)
(Timestamp/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)]
(doseq [r (filter #(= production-mp-id (:metering-point-id %)) readings)]
(let [hour (.getHour (.toLocalDateTime ^Timestamp (:time-dk r)))]
(query-fn :insert-production!
{:time-start time-start :hour hour :kwh (:quantity-kwh r)})))
(doseq [r (filter #(= consumption-mp-id (:metering-point-id %)) readings)]
(let [hour (.getHour (.toLocalDateTime ^Timestamp (:time-dk r)))]
(query-fn :insert-consumption!
{: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]
(api-post access-token
"/api/meteringpoints/meteringpoint/getcharges"
{:meteringPoints {:meteringPoint metering-point-ids}}))
(defn- save-charges!
[query-fn charges-response]
(let [results (:result charges-response)]
(doseq [result results]
(let [mp-id (:id result)
inner (:result result)]
;; Subscriptions
(doseq [sub (:subscriptions inner)]
(query-fn :insert-charge!
{:metering-point-id mp-id
:charge-type "subscription"
:name (:name sub)
:description (:description sub)
:owner (:owner sub)
:valid-from-date (parse-date (:validFromDate sub))
:valid-to-date (parse-date (:validToDate sub))
:period-type (:periodType sub)
:price (:price sub)
:quantity (:quantity sub)
:position 0}))
;; Tariffs (one row per position/price)
(doseq [tar (:tariffs inner)]
(let [prices (or (:prices tar) [])]
(if (seq prices)
(doseq [p prices]
(query-fn :insert-charge!
{:metering-point-id mp-id
:charge-type "tariff"
:name (:name tar)
:description (:description tar)
:owner (:owner tar)
:valid-from-date (parse-date (:validFromDate tar))
:valid-to-date (parse-date (:validToDate tar))
:period-type (:periodType tar)
:price (:price p)
:quantity (:quantity p)
:position (some-> (:position p) str Long/parseLong)}))
(query-fn :insert-charge!
{:metering-point-id mp-id
:charge-type "tariff"
:name (:name tar)
:description (:description tar)
:owner (:owner tar)
:valid-from-date (parse-date (:validFromDate tar))
:valid-to-date (parse-date (:validToDate tar))
:period-type (:periodType tar)
:price nil
:quantity nil
:position 0}))))
;; Fees
(doseq [fee (:fees inner)]
(query-fn :insert-charge!
{:metering-point-id mp-id
:charge-type "fee"
:name (:name fee)
:description (:description fee)
:owner (:owner fee)
:valid-from-date (parse-date (:validFromDate fee))
:valid-to-date (parse-date (:validToDate fee))
:period-type (:periodType fee)
:price (:price fee)
:quantity (:quantity fee)
:position 0}))))))
;; --- Orchestrator ---
(defn fetch-and-save-all!
"Fetch all Eloverblik data and save to database.
date is a LocalDate for which to fetch time series (one day)."
[query-fn ^LocalDate date]
(let [yesterday (.minusDays (LocalDate/now dk-zone) 1)
time-start (utc-start-for-date date)
cnt (:cnt (query-fn :count-consumption-for-date
{:time-start time-start}))]
(cond
(not (.isBefore date yesterday))
(do (log/info "No data to fetch for" (.format date date-fmt) "(too recent)")
{:no-data true :date (.format date date-fmt)})
(and cnt (pos? cnt))
(do (log/info "Data already exists for" (.format date date-fmt))
{:exists true :date (.format date date-fmt)})
:else
(do
(log/info "Starting Eloverblik fetch for" (.format date date-fmt))
(let [refresh-token (read-refresh-token)
access-token (get-access-token refresh-token)]
(if-not access-token
(do (log/error "Failed to get Eloverblik access token")
{:error "Failed to get access token"})
(let [mps (fetch-metering-points access-token)
all-ids (->> mps
(mapcat (fn [mp]
(cons (:meteringPointId mp)
(map :meteringPointId
(:childMeteringPoints mp)))))
(distinct)
(vec))
from date
to (.plusDays date 1)]
(log/info "Found" (count all-ids) "metering points:" all-ids)
(save-metering-points! query-fn mps)
(let [ts-resp (fetch-time-series access-token all-ids from to)
readings (parse-time-series ts-resp all-ids)]
(save-time-series! query-fn readings from))
(let [ch-resp (fetch-charges access-token all-ids)]
(save-charges! query-fn ch-resp))
{:metering-points (count all-ids)
:from (.format from date-fmt)
:to (.format to date-fmt)})))))))
@@ -0,0 +1,15 @@
(ns pmagnus.elprice.web.controllers.health
(:require
[ring.util.http-response :as http-response])
(:import
(java.util
Date)))
(defn healthcheck!
[req]
(http-response/ok
{:time (str (Date. (System/currentTimeMillis)))
:up-since (str (Date. (.getStartTime (java.lang.management.ManagementFactory/getRuntimeMXBean))))
:app {:status "up"
:message ""}}))
@@ -0,0 +1,115 @@
(ns pmagnus.elprice.web.controllers.prices
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log])
(:import
(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]
(str "https://api.energidataservice.dk/dataset/DayAheadPrices"
"?start=" start-date
"&end=" end-date
"&filter=" (URLEncoder/encode (json/write-str {"PriceArea" [price-area]}) "UTF-8")
"&sort=TimeDK%20asc"
"&limit=200"))
(defn- fetch-json
"Fetch JSON from a URL using JDK HttpClient."
[url]
(let [client (HttpClient/newHttpClient)
request (-> (HttpRequest/newBuilder)
(.uri (URI/create url))
(.header "Accept" "application/json")
(.GET)
(.build))
response (.send client request (HttpResponse$BodyHandlers/ofString))]
(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]
(let [url (build-url start end price-area)]
(log/info "Fetching day-ahead prices for" price-area "from" url)
(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."
[]
(let [today (LocalDate/now dk-zone)
start (.format today date-fmt)
end (.format (.plusDays today 2) date-fmt)]
(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" " "))))
(defn save-records!
"Insert price records into the database, skipping existing ones."
[query-fn records]
(doseq [rec records]
(query-fn :insert-price!
{:time-utc (parse-timestamp (:TimeUTC rec))
:time-dk (parse-timestamp (:TimeDK rec))
:price-area (:PriceArea rec)
:price-dkk (:DayAheadPriceDKK rec)
:price-eur (:DayAheadPriceEUR rec)})))
(defn fetch-and-save!
"Fetch prices from API and save to database."
[query-fn]
(when-let [records (fetch-prices-from-api)]
(log/info "Saving" (count records) "price records")
(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]
(query-fn :get-prices-for-date
{: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"))
@@ -0,0 +1,43 @@
(ns pmagnus.elprice.web.controllers.tariffs
(:import
(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
: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
High: 06:00-17:00, 21:00-24:00
Peak: 17:00-21:00"
[hour]
(cond
(< hour 6) :low
(and (<= 6 hour) (< hour 17)) :high
(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."
([^LocalDateTime dt]
(tariff-for-hour (.getMonthValue dt) (.getHour dt)))
([month hour]
(get-in n1-tariffs [(season month) (load-level hour)])))
+48
View File
@@ -0,0 +1,48 @@
(ns pmagnus.elprice.web.handler
(:require
[integrant.core :as ig]
[pmagnus.elprice.web.middleware.core :as middleware]
[reitit.ring :as ring]
[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)
(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")))}))
{: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] :as opts}]
(if (= env :dev)
#(ring/router ["" opts routes])
(constantly (ring/router ["" opts routes]))))
+29
View File
@@ -0,0 +1,29 @@
(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")))
@@ -0,0 +1,14 @@
(ns pmagnus.elprice.web.middleware.core
(:require
[pmagnus.elprice.env :as env]
[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))))))
@@ -0,0 +1,34 @@
(ns pmagnus.elprice.web.middleware.exception
(:require
[clojure.tools.logging :as log]
[reitit.ring.middleware.exception :as exception]))
(defn handler
[message status exception request]
(when (>= status 500)
;; You can optionally use this to report error to an external service
(log/error exception))
{:status status
:body {:message message
:exception (.getClass exception)
:data (ex-data exception)
:uri (:uri request)}})
(def wrap-exception
(exception/create-exception-middleware
(merge
exception/default-handlers
{:system.exception/internal (partial handler "internal exception" 500)
:system.exception/business (partial handler "bad request" 400)
:system.exception/not-found (partial handler "not found" 404)
:system.exception/unauthorized (partial handler "unauthorized" 401)
:system.exception/forbidden (partial handler "forbidden" 403)
;; override the default handler
::exception/default (partial handler "default" 500)
;; print stack-traces for all exceptions
::exception/wrap (fn [handler e request]
(handler e request))})))
@@ -0,0 +1,15 @@
(ns pmagnus.elprice.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)))))
@@ -0,0 +1,57 @@
(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]
[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
muuntaja/format-negotiate-middleware
;; encoding response body
muuntaja/format-response-middleware
;; exception handling
coercion/coerce-exceptions-middleware
;; decoding request body
muuntaja/format-request-middleware
;; coercing response bodys
coercion/coerce-response-middleware
;; coercing request parameters
coercion/coerce-request-middleware
;; exception handling
exception/wrap-exception]})
;; Routes
(defn api-routes
[_opts]
[["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "pmagnus.elprice API"}}
:handler (swagger/create-swagger-handler)}}]
["/health"
;; note that use of the var is necessary
;; for reitit to reload routes without
;; 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 ""}
:as opts}]
(fn [] [base-path route-data (api-routes opts)]))
+269
View File
@@ -0,0 +1,269 @@
(ns pmagnus.elprice.web.routes.ui
(: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.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)))
(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]
(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]
(.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]
(if-let [ore (dkk-mwh->ore-kwh price-dkk)]
(format "%.1f" ore)
"-"))
([price-dkk tariff]
(if-let [ore (dkk-mwh->ore-kwh price-dkk)]
(format "%.1f(%.1f)" (+ ore tariff) ore)
"-")))
(defn- group-by-hour
[prices]
(->> prices
(group-by #(.getHour (->ldt (:time_dk %))))
(sort-by key)))
(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]
(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]
(let [sorted (sort-by #(.getMinute (->ldt (:time_dk %))) quarters)
avg (avg-ore quarters)
month (.getMonthValue (->ldt (:time_dk (first quarters))))
tariff (tariffs/tariff-for-hour month hour)
bg (rank-bg rank total-hours)
dark? (and bg (or (<= rank 4) (>= rank (- total-hours 3))))]
[:details {:class (str "border-2 border-blue-400 rounded-lg mb-2 " bg)}
[:summary {:class (str "flex justify-between py-2 px-3 cursor-pointer font-medium "
(if dark? "text-white" "text-gray-900"))}
[:span (format "%02d:00" hour)]
[:span {:class (str "text-sm " (if dark? "text-white/80" "text-gray-500"))} (str "#" rank)]
[:span {:class "font-mono"}
(if avg (format "%.1f(%.1f)" (+ avg tariff) avg) "-")]]
[:div {:class (str "border-t-2 border-blue-400 px-3 pb-2 "
(if dark? "text-white/80" "text-gray-500"))}
(for [q sorted]
[:div {:class "flex justify-between py-1 ml-4"}
[: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]
(let [totals (for [[hour quarters] grouped-hours]
(let [avg (avg-ore quarters)
month (.getMonthValue (->ldt (:time_dk (first quarters))))
tariff (tariffs/tariff-for-hour month hour)]
[hour (if avg (+ avg tariff) Double/MAX_VALUE)]))]
(->> totals
(sort-by second)
(map-indexed (fn [i [hour _]] [hour (inc i)]))
(into {}))))
(defn- price-table
[prices]
[:div
(if (seq prices)
(let [grouped (group-by-hour prices)
ranks (hour-ranks grouped)
total-hours (count grouped)]
[:div
(for [[hour quarters] grouped]
(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?]
(let [today (java.time.LocalDate/now dk-zone)
tomorrow (.plusDays today 1)]
[:div {:class "flex gap-1 mt-4"}
[:button {:class (if (= active-tab :today) tab-active tab-inactive)
:hx-get "/prices/today"
:hx-target "#price-panel"
:hx-swap "innerHTML"}
(str "Today " (.format today date-fmt))]
(when has-tomorrow?
[:button {:class (if (= active-tab :tomorrow) tab-active tab-inactive)
:hx-get "/prices/tomorrow"
:hx-target "#price-panel"
:hx-swap "innerHTML"}
(str "Tomorrow " (.format tomorrow date-fmt))])]))
(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]
(seq (prices/get-tomorrow-prices query-fn)))
(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))]))
(defn prices-today
[query-fn request]
(fragment
(price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn))))
(defn prices-tomorrow
[query-fn request]
(fragment
(price-content :tomorrow (prices/get-tomorrow-prices query-fn) (has-tomorrow? query-fn))))
(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))))
(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) ")")]))))
;; Routes
(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
[;; Default middleware for ui
;; query-params & form-params
parameters/parameters-middleware
;; encoding response body
muuntaja/format-response-middleware
;; exception handling
exception/wrap-exception]})
(derive :reitit.routes/ui :reitit/routes)
(defmethod ig/init-key :reitit.routes/ui
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
(fn [] [base-path route-data (ui-routes opts)]))
@@ -0,0 +1,13 @@
(ns pmagnus.elprice.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-in req (conj route-data-path k)))
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Sync Eloverblik data for a specific month.
# Usage: ./sync-month.sh 2026-02
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 YYYY-MM"
exit 1
fi
MONTH="$1"
BASE_URL="${BASE_URL:-http://localhost:3030}"
# Last day of the month
days=$(date -d "${MONTH}-01 +1 month -1 day" "+%d")
echo "Syncing $MONTH ($days days)"
for day in $(seq 1 "$days"); do
d=$(printf "%s-%02d" "$MONTH" "$day")
resp=$(curl -s -X POST "${BASE_URL}/eloverblik/sync?date=${d}")
text=$(echo "$resp" | sed 's/<[^>]*>//g' | xargs)
if [ -z "$text" ]; then
echo "$d: no response (server down?)"
exit 1
fi
echo "$d: $text"
done
echo "Done."
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
content: ["./src/**/*.clj", "./resources/**/*.html"],
theme: { extend: {} },
plugins: []
}
+8
View File
@@ -0,0 +1,8 @@
(ns pmagnus.elprice.core-test
(:require
[pmagnus.elprice.test-utils :as utils]
[clojure.test :refer :all]))
(deftest example-test
(is (= 1 2)))
+32
View File
@@ -0,0 +1,32 @@
(ns pmagnus.elprice.test-utils
(:require
[pmagnus.elprice.core :as core]
[peridot.core :as p]
[byte-streams :as bs]
[integrant.repl.state :as state]))
(defn system-state
[]
(or @core/system state/system))
(defn system-fixture
[]
(fn [f]
(when (nil? (system-state))
(core/start-app {:opts {:profile :test}}))
(f)
(core/stop-app)))
(defn get-response [ctx]
(-> ctx
:response
(update :body (fnil bs/to-string ""))))
(defn GET [app path params headers]
(-> (p/session app)
(p/request path
:request-method :get
:content-type "application/edn"
:headers headers
:params params)
(get-response)))
@@ -0,0 +1,13 @@
(ns pmagnus.elprice.web.request-test
(:require [clojure.test :refer [deftest testing is use-fixtures]]
[pmagnus.elprice.test-utils :refer [system-state system-fixture GET]]))
(use-fixtures :once (system-fixture))
(deftest health-request-test []
(testing "happy path"
(let [handler (:handler/ring (system-state))
params {}
headers {}
response (GET handler "/api/health" params headers)]
(is (= 200 (:status response))))))