diff --git a/src/main/java/se/urmo/electricityalert/alert/AlertService.java b/src/main/java/se/urmo/electricityalert/alert/AlertService.java index cc9962a..43a4ca0 100644 --- a/src/main/java/se/urmo/electricityalert/alert/AlertService.java +++ b/src/main/java/se/urmo/electricityalert/alert/AlertService.java @@ -1,6 +1,5 @@ package se.urmo.electricityalert.alert; -import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -12,37 +11,114 @@ import se.urmo.electricityalert.store.InMemoryPriceStore; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -@Slf4j @Service public class AlertService { + private static final Logger log = LoggerFactory.getLogger(AlertService.class); + private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm"); + private final InMemoryPriceStore store; private final TelegramNotifier notifier; private final BigDecimal threshold; + private final boolean quietEnabled; + private final LocalTime quietStart; // e.g. 22:00 + private final LocalTime quietEnd; // e.g. 07:00 (wraps over midnight) + public AlertService(InMemoryPriceStore store, TelegramNotifier notifier, - @Value("${electricity.threshold}") BigDecimal threshold) { + @Value("${electricity.threshold}") BigDecimal threshold, + @Value("${alert.quiet.enabled:true}") boolean quietEnabled, + @Value("${alert.quiet.start:22:00}") String quietStartStr, + @Value("${alert.quiet.end:07:00}") String quietEndStr) { this.store = store; this.notifier = notifier; this.threshold = threshold; + this.quietEnabled = quietEnabled; + this.quietStart = LocalTime.parse(quietStartStr); + this.quietEnd = LocalTime.parse(quietEndStr); } - // Check every 5 minutes + /** Check every 5 minutes. */ @Scheduled(fixedRate = 300_000) public void checkPrices() { - LocalDateTime oneHourLater = LocalDateTime.now() - .truncatedTo(ChronoUnit.HOURS) - .plusHours(1); - ElectricityPrice price = store.get(oneHourLater); - log.debug("Checking price at {} ({})", oneHourLater, price); - 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); + LocalDateTime nowHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS); + LocalDateTime nextHour = nowHour.plusHours(1); + + ElectricityPrice curr = store.get(nowHour); // may be null + ElectricityPrice next = store.get(nextHour); // the hour we consider + + boolean currentCheap = curr != null && curr.price().compareTo(threshold) < 0; + boolean nextCheap = next != null && next.price().compareTo(threshold) < 0; + + // Only alert when crossing into a cheap window (edge-triggered) + if (!currentCheap && nextCheap) { + if (store.alreadyNotified(nextHour)) { + // Window was already notified (e.g., app restart later in same window) + return; + } + + // Build the contiguous cheap window starting at nextHour + LocalDateTime start = nextHour; + LocalDateTime end = start; + LocalDateTime cursor = start; + while (true) { + ElectricityPrice p = store.get(cursor); + if (p == null || p.price().compareTo(threshold) >= 0) break; + end = cursor.plusHours(1); + cursor = cursor.plusHours(1); + } + + // Respect quiet hours: if the window starts during quiet time, skip notify + // (You can change this to "defer to first non-quiet hour" if you wish.) + if (quietEnabled && isWithinQuietHours(start.toLocalTime(), quietStart, quietEnd)) { + log.info("Cheap window {}–{} detected but within quiet hours; skipping alert.", + start.format(HHMM), end.format(HHMM)); + // Still mark as notified to avoid waking you later for the same window. + store.markWindowNotified(start, end); + return; + } + + // Find best (lowest) price hour inside the window for a nicer message + LocalDateTime bestHour = start; + BigDecimal bestPrice = store.get(start).price(); + for (LocalDateTime t = start.plusHours(1); t.isBefore(end); t = t.plusHours(1)) { + ElectricityPrice p = store.get(t); + if (p != null && p.price().compareTo(bestPrice) < 0) { + bestPrice = p.price(); + bestHour = t; + } + } + + // Send alert + String msg = String.format( + "⚡ Cheap window %s–%s (best at %s = %.3f SEK/kWh). Plug in the car!", + start.format(HHMM), end.format(HHMM), bestHour.format(HHMM), bestPrice + ); + try { + notifier.send(msg); + log.info("Sent alert: {}", msg); + } catch (Exception e) { + log.warn("Failed to send alert message", e); + } + + // Mark the whole window as notified to prevent repeats + store.markWindowNotified(start, end); + } + } + + /** Handles ranges that may wrap over midnight (e.g., 22:00–07:00). */ + static boolean isWithinQuietHours(LocalTime t, LocalTime start, LocalTime end) { + if (start.equals(end)) return true; // whole day quiet + if (start.isBefore(end)) { + // Normal case: e.g., 21:00–23:00 + return !t.isBefore(start) && t.isBefore(end); + } else { + // Wrap-around: e.g., 22:00–07:00 + return !t.isBefore(start) || t.isBefore(end); } } } diff --git a/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java index 0a1aa8f..2760a4e 100644 --- a/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java +++ b/src/main/java/se/urmo/electricityalert/fetcher/PriceFetcherService.java @@ -15,6 +15,7 @@ import se.urmo.electricityalert.store.InMemoryPriceStore; import java.time.OffsetDateTime; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -115,11 +116,10 @@ public class PriceFetcherService { 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) - + LocalDateTime hourLocal = odt.toLocalDateTime().truncatedTo(ChronoUnit.HOURS); store.put(new ElectricityPrice(hourLocal, p.total())); + stored++; } return stored; diff --git a/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java index 110fbc1..a5a2ad8 100644 --- a/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java +++ b/src/main/java/se/urmo/electricityalert/store/InMemoryPriceStore.java @@ -1,5 +1,6 @@ package se.urmo.electricityalert.store; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import se.urmo.electricityalert.model.ElectricityPrice; @@ -21,10 +22,6 @@ public class InMemoryPriceStore { return prices.get(hour); } - public Map all() { - return prices; - } - public boolean alreadyNotified(LocalDateTime hour) { return notified.contains(hour); } @@ -32,4 +29,19 @@ public class InMemoryPriceStore { public void markNotified(LocalDateTime hour) { notified.add(hour); } + + /** Mark a whole range [start, end) in hours as notified. */ + public void markWindowNotified(LocalDateTime startInclusive, LocalDateTime endExclusive) { + for (LocalDateTime t = startInclusive; t.isBefore(endExclusive); t = t.plusHours(1)) { + notified.add(t); + } + } + + /** Keep memory tidy: drop entries older than ~2 days. Runs at 00:02 daily. */ + @Scheduled(cron = "0 2 0 * * *") + public void cleanup() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(2); + prices.keySet().removeIf(ts -> ts.isBefore(cutoff)); + notified.removeIf(ts -> ts.isBefore(cutoff)); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ae95d37..c62a254 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,3 +12,8 @@ graphql.access-token=${GRAPHQL_ACCESS_TOKEN} # ?? Behavior fetch.on-startup=${FETCH_ON_STARTUP:true} + +# Quiet hours (local time) +alert.quiet.enabled=${ALERT_QUIET_ENABLED:true} +alert.quiet.start=${ALERT_QUIET_START:22:00} +alert.quiet.end=${ALERT_QUIET_END:07:00}