diff --git a/.gitignore b/.gitignore
index 9154f4c..d15ac82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,104 @@
-# ---> Java
+/target/
+!.mvn/wrapper/maven-wrapper.jar
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+
+# Created by https://www.gitignore.io/api/git,java,maven,eclipse,windows
+
+### Eclipse ###
+
+.metadata
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
+.recommenders
+
+# External tool builders
+.externalToolBuilders/
+
+# Locally stored "Eclipse launch configurations"
+*.launch
+
+# PyDev specific (Python IDE for Eclipse)
+*.pydevproject
+
+# CDT-specific (C/C++ Development Tooling)
+.cproject
+
+# CDT- autotools
+.autotools
+
+# Java annotation processor (APT)
+.factorypath
+
+# PDT-specific (PHP Development Tools)
+.buildpath
+
+# sbteclipse plugin
+.target
+
+# Tern plugin
+.tern-project
+
+# TeXlipse plugin
+.texlipse
+
+# STS (Spring Tool Suite)
+.springBeans
+
+# Code Recommenders
+.recommenders/
+
+# Annotation Processing
+.apt_generated/
+
+# Scala IDE specific (Scala & Java development for Eclipse)
+.cache-main
+.scala_dependencies
+.worksheet
+
+### Eclipse Patch ###
+# Eclipse Core
+.project
+
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+# Annotation Processing
+.apt_generated
+
+.sts4-cache/
+
+### Git ###
+# Created by git for backups. To disable backups in Git:
+# $ git config --global mergetool.keepBackup false
+*.orig
+
+# Created by git when using merge tools for conflicts
+*.BACKUP.*
+*.BASE.*
+*.LOCAL.*
+*.REMOTE.*
+*_BACKUP_*.txt
+*_BASE_*.txt
+*_LOCAL_*.txt
+*_REMOTE_*.txt
+
+### Java ###
# Compiled class file
*.class
@@ -22,5 +122,80 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
-replay_pid*
+### Maven ###
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+.mvn/wrapper/maven-wrapper.jar
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Some additional ignores (sort later)
+*.DS_Store
+*.sw?
+.#*
+*#
+*~
+.classpath
+.project
+.settings
+bin
+build
+target
+dependency-reduced-pom.xml
+*.sublime-*
+/scratch
+.gradle
+README.html
+*.iml
+.idea
+.exercism
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..6c7dfd4
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,85 @@
+
+ 4.0.0
+
+
+ se.urmo
+ electricityalert
+ 0.0.1-SNAPSHOT
+ Electricity Price Alert
+ Alert system for electricity price using Telegram
+ jar
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.5
+
+
+
+
+ 21
+ UTF-8
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ com.github.pengrad
+ java-telegram-bot-api
+ 9.2.0
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/setup_project.sh b/setup_project.sh
new file mode 100644
index 0000000..d86b9c9
--- /dev/null
+++ b/setup_project.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+BASE_DIR="src/main"
+JAVA_DIR="$BASE_DIR/java/se/urmo/electricityalert"
+RES_DIR="$BASE_DIR/resources"
+
+# Create directory structure
+mkdir -p "$JAVA_DIR/config"
+mkdir -p "$JAVA_DIR/fetcher"
+mkdir -p "$JAVA_DIR/alert"
+mkdir -p "$JAVA_DIR/notifier"
+mkdir -p "$JAVA_DIR/store"
+mkdir -p "$JAVA_DIR/model"
+mkdir -p "$RES_DIR"
+
+# Create empty Java files
+touch "$JAVA_DIR/ElectricityAlertApplication.java"
+touch "$JAVA_DIR/config/AppConfig.java"
+touch "$JAVA_DIR/fetcher/PriceFetcherService.java"
+touch "$JAVA_DIR/alert/AlertService.java"
+touch "$JAVA_DIR/notifier/TelegramNotifier.java"
+touch "$JAVA_DIR/store/InMemoryPriceStore.java"
+touch "$JAVA_DIR/model/ElectricityPrice.java"
+
+# Create application.properties
+touch "$RES_DIR/application.properties"
+
+echo "Project structure and base files created."
+
diff --git a/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java b/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java
new file mode 100644
index 0000000..8cf3497
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/ElectricityAlertApplication.java
@@ -0,0 +1,13 @@
+package se.urmo.electricityalert;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableScheduling
+public class ElectricityAlertApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ElectricityAlertApplication.class, args);
+ }
+}
diff --git a/src/main/java/se/urmo/electricityalert/alert/AlertService.java b/src/main/java/se/urmo/electricityalert/alert/AlertService.java
new file mode 100644
index 0000000..6d101a2
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/alert/AlertService.java
@@ -0,0 +1,45 @@
+package se.urmo.electricityalert.alert;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import se.urmo.electricityalert.model.ElectricityPrice;
+import se.urmo.electricityalert.notifier.TelegramNotifier;
+import se.urmo.electricityalert.store.InMemoryPriceStore;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Service
+public class AlertService {
+ private static final Logger log = LoggerFactory.getLogger(AlertService.class);
+
+ private final InMemoryPriceStore store;
+ private final TelegramNotifier notifier;
+ private final BigDecimal threshold;
+
+ public AlertService(InMemoryPriceStore store,
+ TelegramNotifier notifier,
+ @Value("${electricity.threshold}") BigDecimal threshold) {
+ this.store = store;
+ this.notifier = notifier;
+ this.threshold = threshold;
+ }
+
+ // Check every 5 minutes
+ @Scheduled(fixedRate = 300_000)
+ public void checkPrices() {
+ LocalDateTime oneHourLater = LocalDateTime.now().withMinute(0).withSecond(0).plusHours(1);
+ ElectricityPrice price = store.get(oneHourLater);
+
+ if (price != null && price.price().compareTo(threshold) < 0 && !store.alreadyNotified(oneHourLater)) {
+ String msg = String.format("⚡ In the next hour (%s) the price is %.2f SEK/kWh. Plug in the car!",
+ oneHourLater, price.price());
+ notifier.send(msg);
+ store.markNotified(oneHourLater);
+ log.info("Sent alert: {}", msg);
+ }
+ }
+}
diff --git a/src/main/java/se/urmo/electricityalert/config/AppConfig.java b/src/main/java/se/urmo/electricityalert/config/AppConfig.java
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java
new file mode 100644
index 0000000..0a1aa8f
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java
@@ -0,0 +1,127 @@
+package se.urmo.electricityalert.fetcher;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.web.client.RestClient;
+import se.urmo.electricityalert.fetcher.dto.PriceInfoDto;
+import se.urmo.electricityalert.model.ElectricityPrice;
+import se.urmo.electricityalert.store.InMemoryPriceStore;
+
+import java.time.OffsetDateTime;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class PriceFetcherService {
+
+ private static final Logger log = LoggerFactory.getLogger(PriceFetcherService.class);
+
+ private final InMemoryPriceStore store;
+ private final RestClient client;
+ private final boolean fetchOnStartup;
+
+
+ // Your GraphQL query (exactly as you provided, compacted into a single string)
+ private static final String QUERY = """
+ { viewer { homes { currentSubscription { priceInfo {
+ current { total energy tax startsAt }
+ today { total energy tax startsAt }
+ tomorrow { total energy tax startsAt }
+ } } } } }
+ """;
+
+ public PriceFetcherService(
+ InMemoryPriceStore store,
+ @Value("${graphql.endpoint}") String endpoint,
+ @Value("${graphql.access-token}") String accessToken,
+ @Value("${fetch.on-startup:true}") boolean fetchOnStartup) {
+ this.store = store;
+ this.fetchOnStartup = fetchOnStartup;
+ this.client = RestClient.builder()
+ .baseUrl(endpoint)
+ .defaultHeader("Authorization", "Bearer " + accessToken)
+ .build();
+ }
+
+ /** Runs every day at 13:05. */
+ @Scheduled(cron = "0 5 13 * * *")
+ public void fetchPrices() {
+ fetchPricesInternal();
+ }
+
+ /** One-time fetch after the app is fully started (beans initialized, web server up). */
+ @EventListener(ApplicationReadyEvent.class)
+ public void onReadyFetchOnce() {
+ if (!fetchOnStartup) return;
+ log.info("Application started — performing one-time price fetch...");
+ fetchPricesInternal();
+ }
+
+ /** Shared logic used by both the scheduled job and the startup hook. */
+ private void fetchPricesInternal() {
+ try {
+ Map body = new HashMap<>();
+ body.put("query", QUERY);
+
+ PriceInfoDto result = client.post()
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_JSON)
+ .body(body)
+ .retrieve()
+ .body(PriceInfoDto.class);
+
+ if (result == null || result.data() == null || result.data().viewer() == null
+ || result.data().viewer().homes() == null || result.data().viewer().homes().isEmpty()) {
+ log.warn("GraphQL response missing expected fields; nothing stored.");
+ return;
+ }
+
+ var home = result.data().viewer().homes().get(0);
+ if (home == null || home.currentSubscription() == null
+ || home.currentSubscription().priceInfo() == null) {
+ log.warn("No priceInfo in response; nothing stored.");
+ return;
+ }
+
+ var priceInfo = home.currentSubscription().priceInfo();
+ int count = 0;
+
+ // current
+ count += storePoints(List.of(priceInfo.current()));
+ // today
+ if (priceInfo.today() != null) count += storePoints(priceInfo.today());
+ // tomorrow
+ if (priceInfo.tomorrow() != null) count += storePoints(priceInfo.tomorrow());
+
+ log.info("Stored {} price points from GraphQL.", count);
+
+ } catch (Exception e) {
+ log.error("Failed to fetch/parse GraphQL prices", e);
+ }
+ }
+
+ private int storePoints(List points) {
+ if (points == null || points.isEmpty()) return 0;
+
+ int stored = 0;
+ for (var p : points) {
+ if (p == null || p.startsAt() == null || p.total() == null) continue;
+
+ // startsAt like: 2025-09-12T11:00:00.000+02:00
+ OffsetDateTime odt = OffsetDateTime.parse(p.startsAt());
+ LocalDateTime hourLocal = odt.toLocalDateTime(); // interpret in local (offset already in string)
+
+ store.put(new ElectricityPrice(hourLocal, p.total()));
+ stored++;
+ }
+ return stored;
+ }
+}
diff --git a/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java b/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java
new file mode 100644
index 0000000..579653b
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/fetcher/dto/PriceInfoDto.java
@@ -0,0 +1,13 @@
+package se.urmo.electricityalert.fetcher.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public record PriceInfoDto(Data data) {
+ public record Data(Viewer viewer) {}
+ public record Viewer(List homes) {}
+ public record Home(CurrentSubscription currentSubscription) {}
+ public record CurrentSubscription(PriceInfo priceInfo) {}
+ public record PriceInfo(PricePoint current, List today, List tomorrow) {}
+ public record PricePoint(BigDecimal total, BigDecimal energy, BigDecimal tax, String startsAt) {}
+}
diff --git a/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java b/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java
new file mode 100644
index 0000000..9143755
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/model/ElectricityPrice.java
@@ -0,0 +1,7 @@
+package se.urmo.electricityalert.model;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+public record ElectricityPrice(LocalDateTime hour, BigDecimal price) {
+}
diff --git a/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java b/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java
new file mode 100644
index 0000000..fb18592
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/notifier/TelegramNotifier.java
@@ -0,0 +1,22 @@
+package se.urmo.electricityalert.notifier;
+
+import com.pengrad.telegrambot.TelegramBot;
+import com.pengrad.telegrambot.request.SendMessage;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+public class TelegramNotifier {
+ private final TelegramBot bot;
+ private final String chatId;
+
+ public TelegramNotifier(@Value("${telegram.bot.token}") String token,
+ @Value("${telegram.chat.id}") String chatId) {
+ this.bot = new TelegramBot(token);
+ this.chatId = chatId;
+ }
+
+ public void send(String message) {
+ bot.execute(new SendMessage(chatId, message));
+ }
+}
diff --git a/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java
new file mode 100644
index 0000000..110fbc1
--- /dev/null
+++ b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java
@@ -0,0 +1,35 @@
+package se.urmo.electricityalert.store;
+
+import org.springframework.stereotype.Component;
+import se.urmo.electricityalert.model.ElectricityPrice;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class InMemoryPriceStore {
+ private final Map prices = new ConcurrentHashMap<>();
+ private final Set notified = ConcurrentHashMap.newKeySet();
+
+ public void put(ElectricityPrice price) {
+ prices.put(price.hour(), price);
+ }
+
+ public ElectricityPrice get(LocalDateTime hour) {
+ return prices.get(hour);
+ }
+
+ public Map all() {
+ return prices;
+ }
+
+ public boolean alreadyNotified(LocalDateTime hour) {
+ return notified.contains(hour);
+ }
+
+ public void markNotified(LocalDateTime hour) {
+ notified.add(hour);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..7a3e216
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,13 @@
+# ? Telegram Bot
+telegram.bot.token=${TELEGRAM_BOT_TOKEN}
+telegram.chat.id=${TELEGRAM_CHAT_ID}
+
+# ? Electricity threshold (can also override via ENV if you want)
+electricity.threshold=${ELECTRICITY_THRESHOLD:0.50}
+
+# ? GraphQL
+graphql.endpoint=${GRAPHQL_ENDPOINT:https://provider.example/graphql}
+graphql.access-token=${GRAPHQL_ACCESS_TOKEN}
+
+# ?? Behavior
+fetch.on-startup=${FETCH_ON_STARTUP:true}