diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlert.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlert.java
new file mode 100644
index 0000000..8b18354
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlert.java
@@ -0,0 +1,177 @@
+package pl.polskalokalnie.pricealert;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import pl.polskalokalnie.listing.PropertyType;
+
+/**
+ * Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
+ * Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
+ * otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
+ *
+ *
Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.
+ */
+@Entity
+@Table(name = "price_alerts")
+public class PriceAlert {
+
+ public enum Kind {
+ /** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
+ LISTING,
+ /** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
+ LOCATION
+ }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_email", nullable = false, length = 180)
+ private String userEmail;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private Kind kind;
+
+ /** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
+ @Column(nullable = false, length = 200)
+ private String label;
+
+ // --- alert ogloszenia ---
+
+ @Column(name = "listing_id")
+ private Long listingId;
+
+ // --- alert lokalizacji ---
+
+ @Column(length = 120)
+ private String city;
+
+ /** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
+ @Column(length = 60)
+ private String region;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "property_type", length = 20)
+ private PropertyType propertyType;
+
+ /** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
+ @Column(length = 20)
+ private String market;
+
+ /** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
+ @Column(name = "last_value")
+ private Long lastValue;
+
+ @Column(name = "last_checked_at")
+ private Instant lastCheckedAt;
+
+ @Column(name = "notifications_enabled", nullable = false)
+ private boolean notificationsEnabled = true;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt = Instant.now();
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getUserEmail() {
+ return userEmail;
+ }
+
+ public void setUserEmail(String userEmail) {
+ this.userEmail = userEmail;
+ }
+
+ public Kind getKind() {
+ return kind;
+ }
+
+ public void setKind(Kind kind) {
+ this.kind = kind;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public Long getListingId() {
+ return listingId;
+ }
+
+ public void setListingId(Long listingId) {
+ this.listingId = listingId;
+ }
+
+ public String getCity() {
+ return city;
+ }
+
+ public void setCity(String city) {
+ this.city = city;
+ }
+
+ public String getRegion() {
+ return region;
+ }
+
+ public void setRegion(String region) {
+ this.region = region;
+ }
+
+ public PropertyType getPropertyType() {
+ return propertyType;
+ }
+
+ public void setPropertyType(PropertyType propertyType) {
+ this.propertyType = propertyType;
+ }
+
+ public String getMarket() {
+ return market;
+ }
+
+ public void setMarket(String market) {
+ this.market = market;
+ }
+
+ public Long getLastValue() {
+ return lastValue;
+ }
+
+ public void setLastValue(Long lastValue) {
+ this.lastValue = lastValue;
+ }
+
+ public Instant getLastCheckedAt() {
+ return lastCheckedAt;
+ }
+
+ public void setLastCheckedAt(Instant lastCheckedAt) {
+ this.lastCheckedAt = lastCheckedAt;
+ }
+
+ public boolean isNotificationsEnabled() {
+ return notificationsEnabled;
+ }
+
+ public void setNotificationsEnabled(boolean notificationsEnabled) {
+ this.notificationsEnabled = notificationsEnabled;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertChecker.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertChecker.java
new file mode 100644
index 0000000..3d16612
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertChecker.java
@@ -0,0 +1,45 @@
+package pl.polskalokalnie.pricealert;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
+ * zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
+ * jedno powiadomienie na alert dziennie, bez zasypywania listy.
+ */
+@Component
+public class PriceAlertChecker {
+
+ private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
+
+ private final PriceAlertService priceAlertService;
+
+ public PriceAlertChecker(PriceAlertService priceAlertService) {
+ this.priceAlertService = priceAlertService;
+ }
+
+ @Scheduled(cron = "0 0 3 * * *")
+ public void checkDaily() {
+ run();
+ }
+
+ /** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
+ @Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
+ public void checkOnStartup() {
+ run();
+ }
+
+ private void run() {
+ try {
+ int notified = priceAlertService.checkAll();
+ if (notified > 0) {
+ log.info("Alerty cenowe: wyslano {} powiadomien", notified);
+ }
+ } catch (RuntimeException ex) {
+ log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
+ }
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertController.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertController.java
new file mode 100644
index 0000000..d272e6c
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertController.java
@@ -0,0 +1,60 @@
+package pl.polskalokalnie.pricealert;
+
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
+
+/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
+@RestController
+@RequestMapping("/api/price-alerts")
+public class PriceAlertController {
+
+ private final PriceAlertService priceAlertService;
+
+ public PriceAlertController(PriceAlertService priceAlertService) {
+ this.priceAlertService = priceAlertService;
+ }
+
+ @GetMapping
+ public List list(Authentication authentication) {
+ return priceAlertService.list(authentication.getName());
+ }
+
+ /** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
+ @GetMapping("/events")
+ public List events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
+ return priceAlertService.events(authentication.getName(), days);
+ }
+
+ @PostMapping
+ public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
+ return priceAlertService.create(authentication.getName(), request);
+ }
+
+ public record NotificationsRequest(boolean enabled) {
+ }
+
+ @PutMapping("/{id}/notifications")
+ public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
+ Authentication authentication) {
+ return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity delete(@PathVariable Long id, Authentication authentication) {
+ priceAlertService.delete(authentication.getName(), id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertDtos.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertDtos.java
new file mode 100644
index 0000000..6fce2a4
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertDtos.java
@@ -0,0 +1,30 @@
+package pl.polskalokalnie.pricealert;
+
+import java.time.Instant;
+
+public final class PriceAlertDtos {
+
+ private PriceAlertDtos() {
+ }
+
+ /**
+ * Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
+ * ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
+ */
+ public record PriceAlertRequest(Long listingId, String label, String city, String region,
+ String propertyType, String market) {
+ }
+
+ /**
+ * kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
+ * a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
+ */
+ public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
+ String propertyType, String market, Long lastValue, Instant lastCheckedAt,
+ boolean notificationsEnabled, Instant createdAt) {
+ }
+
+ public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
+ long previousValue, long currentValue, Instant changedAt) {
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEvent.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEvent.java
new file mode 100644
index 0000000..96ca80b
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEvent.java
@@ -0,0 +1,113 @@
+package pl.polskalokalnie.pricealert;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+
+/**
+ * Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
+ * ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
+ */
+@Entity
+@Table(name = "price_alert_events")
+public class PriceAlertEvent {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "alert_id", nullable = false)
+ private Long alertId;
+
+ @Column(name = "user_email", nullable = false, length = 180)
+ private String userEmail;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private PriceAlert.Kind kind;
+
+ @Column(nullable = false, length = 200)
+ private String label;
+
+ @Column(name = "listing_id")
+ private Long listingId;
+
+ @Column(name = "previous_value", nullable = false)
+ private long previousValue;
+
+ @Column(name = "current_value", nullable = false)
+ private long currentValue;
+
+ @Column(name = "changed_at", nullable = false)
+ private Instant changedAt = Instant.now();
+
+ public Long getId() {
+ return id;
+ }
+
+ public Long getAlertId() {
+ return alertId;
+ }
+
+ public void setAlertId(Long alertId) {
+ this.alertId = alertId;
+ }
+
+ public String getUserEmail() {
+ return userEmail;
+ }
+
+ public void setUserEmail(String userEmail) {
+ this.userEmail = userEmail;
+ }
+
+ public PriceAlert.Kind getKind() {
+ return kind;
+ }
+
+ public void setKind(PriceAlert.Kind kind) {
+ this.kind = kind;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public Long getListingId() {
+ return listingId;
+ }
+
+ public void setListingId(Long listingId) {
+ this.listingId = listingId;
+ }
+
+ public long getPreviousValue() {
+ return previousValue;
+ }
+
+ public void setPreviousValue(long previousValue) {
+ this.previousValue = previousValue;
+ }
+
+ public long getCurrentValue() {
+ return currentValue;
+ }
+
+ public void setCurrentValue(long currentValue) {
+ this.currentValue = currentValue;
+ }
+
+ public Instant getChangedAt() {
+ return changedAt;
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEventRepository.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEventRepository.java
new file mode 100644
index 0000000..f400ea9
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertEventRepository.java
@@ -0,0 +1,12 @@
+package pl.polskalokalnie.pricealert;
+
+import java.time.Instant;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PriceAlertEventRepository extends JpaRepository {
+
+ List findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
+
+ void deleteByAlertId(Long alertId);
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertRepository.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertRepository.java
new file mode 100644
index 0000000..9eb5a48
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertRepository.java
@@ -0,0 +1,14 @@
+package pl.polskalokalnie.pricealert;
+
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PriceAlertRepository extends JpaRepository {
+
+ List findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
+
+ Optional findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
+
+ List findByKind(PriceAlert.Kind kind);
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java
new file mode 100644
index 0000000..534fa00
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java
@@ -0,0 +1,248 @@
+package pl.polskalokalnie.pricealert;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Locale;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+import pl.polskalokalnie.listing.ListingRepository;
+import pl.polskalokalnie.listing.ListingStatus;
+import pl.polskalokalnie.listing.OfferType;
+import pl.polskalokalnie.listing.PropertyType;
+import pl.polskalokalnie.listing.PropertyListing;
+import pl.polskalokalnie.notification.NotificationService;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
+import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
+import pl.polskalokalnie.pricehistory.PriceSnapshotService;
+
+/**
+ * Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
+ *
+ *
Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
+ * uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
+ * ruchu pocztowego.
+ */
+@Service
+public class PriceAlertService {
+
+ private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
+
+ /**
+ * Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
+ * progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
+ * zmiana jest dla obserwujacego istotna.
+ */
+ static final double LOCATION_MIN_CHANGE_PCT = 2.0;
+
+ private final PriceAlertRepository alertRepository;
+ private final PriceAlertEventRepository eventRepository;
+ private final ListingRepository listingRepository;
+ private final PriceSnapshotService snapshotService;
+ private final NotificationService notificationService;
+
+ public PriceAlertService(PriceAlertRepository alertRepository,
+ PriceAlertEventRepository eventRepository,
+ ListingRepository listingRepository,
+ PriceSnapshotService snapshotService,
+ NotificationService notificationService) {
+ this.alertRepository = alertRepository;
+ this.eventRepository = eventRepository;
+ this.listingRepository = listingRepository;
+ this.snapshotService = snapshotService;
+ this.notificationService = notificationService;
+ }
+
+ @Transactional(readOnly = true)
+ public List list(String userEmail) {
+ return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
+ .map(PriceAlertService::toResponse)
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public List events(String userEmail, int days) {
+ Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
+ return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
+ .map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
+ event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
+ event.getChangedAt()))
+ .toList();
+ }
+
+ @Transactional
+ public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
+ if (request == null) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
+ }
+ PriceAlert alert = request.listingId() != null
+ ? buildListingAlert(userEmail, request.listingId())
+ : buildLocationAlert(userEmail, request);
+ alert.setLastValue(measure(alert));
+ alert.setLastCheckedAt(Instant.now());
+ return toResponse(alertRepository.save(alert));
+ }
+
+ @Transactional
+ public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
+ PriceAlert alert = requireOwn(userEmail, id);
+ alert.setNotificationsEnabled(enabled);
+ return toResponse(alert);
+ }
+
+ @Transactional
+ public void delete(String userEmail, Long id) {
+ PriceAlert alert = requireOwn(userEmail, id);
+ eventRepository.deleteByAlertId(alert.getId());
+ alertRepository.delete(alert);
+ }
+
+ /**
+ * Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
+ * powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
+ */
+ @Transactional
+ public int checkAll() {
+ int notified = 0;
+ for (PriceAlert alert : alertRepository.findAll()) {
+ try {
+ if (checkOne(alert)) {
+ notified++;
+ }
+ } catch (RuntimeException ex) {
+ log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
+ }
+ }
+ return notified;
+ }
+
+ private boolean checkOne(PriceAlert alert) {
+ Long current = measure(alert);
+ if (current == null) {
+ return false;
+ }
+ Long previous = alert.getLastValue();
+ alert.setLastValue(current);
+ alert.setLastCheckedAt(Instant.now());
+ if (previous == null || previous <= 0 || previous.equals(current)) {
+ return false;
+ }
+ double changePct = ((current - previous) * 100.0) / previous;
+ if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
+ return false;
+ }
+
+ PriceAlertEvent event = new PriceAlertEvent();
+ event.setAlertId(alert.getId());
+ event.setUserEmail(alert.getUserEmail());
+ event.setKind(alert.getKind());
+ event.setLabel(alert.getLabel());
+ event.setListingId(alert.getListingId());
+ event.setPreviousValue(previous);
+ event.setCurrentValue(current);
+ eventRepository.save(event);
+
+ if (alert.isNotificationsEnabled()) {
+ notify(alert, previous, current, changePct);
+ }
+ return true;
+ }
+
+ private void notify(PriceAlert alert, long previous, long current, double changePct) {
+ boolean down = current < previous;
+ String direction = down ? "spadła" : "wzrosła";
+ String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
+ ? "listing:" + alert.getListingId()
+ : "priceAlerts";
+ String title = alert.getKind() == PriceAlert.Kind.LISTING
+ ? "Cena " + direction + ": " + alert.getLabel()
+ : "Zmiana cen: " + alert.getLabel();
+ String body = alert.getKind() == PriceAlert.Kind.LISTING
+ ? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
+ direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
+ : String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
+ direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
+ // dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
+ String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
+ notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
+ title, body, link, dedupeKey);
+ }
+
+ /** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
+ private Long measure(PriceAlert alert) {
+ if (alert.getKind() == PriceAlert.Kind.LISTING) {
+ return listingRepository.findById(alert.getListingId())
+ .filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
+ .map(PropertyListing::getPrice)
+ .map(price -> price.longValue())
+ .orElse(null);
+ }
+ PriceSnapshotService.Average average = snapshotService.currentAverage(
+ OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
+ alert.getCity(), "");
+ return average == null ? null : average.avgPricePerM2();
+ }
+
+ private PriceAlert buildListingAlert(String userEmail, Long listingId) {
+ PropertyListing listing = listingRepository.findById(listingId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
+ PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
+ if (existing != null) {
+ return existing;
+ }
+ PriceAlert alert = new PriceAlert();
+ alert.setUserEmail(userEmail);
+ alert.setKind(PriceAlert.Kind.LISTING);
+ alert.setListingId(listingId);
+ alert.setLabel(listing.getTitle());
+ return alert;
+ }
+
+ private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
+ if (request.city() == null || request.city().isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
+ }
+ String city = request.city().trim();
+ PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
+ String market = request.market() == null ? "" : request.market().trim();
+
+ PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
+ .filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
+ .filter(item -> city.equalsIgnoreCase(item.getCity()))
+ .filter(item -> item.getPropertyType() == propertyType)
+ .filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
+ .findFirst()
+ .orElseGet(PriceAlert::new);
+ alert.setUserEmail(userEmail);
+ alert.setKind(PriceAlert.Kind.LOCATION);
+ alert.setCity(city);
+ alert.setRegion(request.region() == null ? null : request.region().trim());
+ alert.setPropertyType(propertyType);
+ alert.setMarket(market);
+ alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
+ return alert;
+ }
+
+ private PriceAlert requireOwn(String userEmail, Long id) {
+ return alertRepository.findById(id)
+ .filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
+ }
+
+ private static String money(long value) {
+ return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
+ }
+
+ private static PriceAlertResponse toResponse(PriceAlert alert) {
+ return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
+ alert.getCity(), alert.getRegion(),
+ alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
+ alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
+ alert.isNotificationsEnabled(), alert.getCreatedAt());
+ }
+}
diff --git a/backend/src/test/java/pl/polskalokalnie/pricealert/PriceAlertThresholdTest.java b/backend/src/test/java/pl/polskalokalnie/pricealert/PriceAlertThresholdTest.java
new file mode 100644
index 0000000..31ca8f4
--- /dev/null
+++ b/backend/src/test/java/pl/polskalokalnie/pricealert/PriceAlertThresholdTest.java
@@ -0,0 +1,30 @@
+package pl.polskalokalnie.pricealert;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
+ * jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
+ */
+class PriceAlertThresholdTest {
+
+ private boolean notifiesLocation(long previous, long current) {
+ double changePct = ((current - previous) * 100.0) / previous;
+ return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
+ }
+
+ @Test
+ void ignoresSmallMoveOfTheAverage() {
+ // 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
+ assertThat(notifiesLocation(14_000, 14_200)).isFalse();
+ assertThat(notifiesLocation(14_000, 13_800)).isFalse();
+ }
+
+ @Test
+ void notifiesAboutRealMove() {
+ assertThat(notifiesLocation(14_000, 14_280)).isTrue();
+ assertThat(notifiesLocation(14_659, 13_523)).isTrue();
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0a306cc..d4f1220 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -296,37 +296,6 @@ type FavoriteListing = {
badge: string;
};
-type FavoritePriceAlertEvent = {
- id: string;
- listingId: number;
- title: string;
- location: string;
- area: string;
- offerType?: ApiOfferType;
- previousPrice: number;
- currentPrice: number;
- changedAt: string;
-};
-
-type CityPriceAlertSnapshot = {
- dayKey: string;
- perM2: number;
- updatedAt: string;
-};
-
-type CityPriceAlertEvent = {
- id: string;
- subscriptionKey: string;
- label: string;
- city: string;
- region: string;
- propertyType: HistoryPropertyType;
- marketType: HistoryMarketType;
- previousPerM2: number;
- currentPerM2: number;
- changedAt: string;
-};
-
type CityPriceAlertSubscription = {
key: string;
label: string;
@@ -350,6 +319,37 @@ type ListingPriceAlertSubscription = {
notificationsEnabled?: boolean;
};
+/**
+ * Alert cenowy z serwera. Zmiany wykrywa serwer raz dziennie i zapisuje je na liscie
+ * powiadomien uzytkownika - z alertow nie wychodza zadne e-maile.
+ */
+type ApiPriceAlert = {
+ id: number;
+ kind: 'LISTING' | 'LOCATION';
+ label: string;
+ listingId: number | null;
+ city: string | null;
+ region: string | null;
+ propertyType: 'APARTMENT' | 'HOUSE' | null;
+ market: string | null;
+ lastValue: number | null;
+ lastCheckedAt: string | null;
+ notificationsEnabled: boolean;
+ createdAt: string;
+};
+
+/** Zarejestrowana zmiana ceny - z tego powstaja liczniki "spadki/wzrosty" na stronie alertow. */
+type ApiPriceAlertEvent = {
+ id: number;
+ alertId: number;
+ kind: 'LISTING' | 'LOCATION';
+ label: string;
+ listingId: number | null;
+ previousValue: number;
+ currentValue: number;
+ changedAt: string;
+};
+
type SavedSearchMode = 'buy' | 'rent';
type SavedSearchRecord = {
@@ -370,14 +370,12 @@ type SavedSearchRecord = {
updatedAt: string;
};
-const FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX = 'favorite-price-alert-events';
-const FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX = 'favorite-price-snapshots';
const FAVORITE_LISTINGS_STORAGE_SUFFIX = 'favorite-listings';
const FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX = 'favorites-preferred-tab';
+// Klucze zostaja tylko po to, zeby raz przeniesc na konto alerty zalozone w przegladarce
+// przed przeniesieniem tej funkcji na serwer.
const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions';
const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions';
-const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots';
-const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events';
// Klucz zostaje tylko po to, zeby raz przeniesc na konto wyszukiwania zapisane w przegladarce
// przed przeniesieniem tej funkcji na serwer.
const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches';
@@ -466,7 +464,6 @@ function savePriceHistoryMarkets(markets: PriceHistoryMarket[]): void {
.catch(() => { /* brak sieci - lista wroci do stanu z konta przy nastepnym wejsciu */ });
}
const PRICE_HISTORY_MARKETS_LIMIT = 5;
-const PRICE_ALERT_EVENTS_LIMIT = 40;
const SAVED_SEARCHES_LIMIT = 60;
function readUserJsonStorage(user: AuthUser | null, suffix: string, fallback: T): T {
@@ -491,25 +488,6 @@ function writeUserJsonStorage(user: AuthUser | null, suffix: string, value: T
window.localStorage.setItem(accountStorageKey(suffix, user), JSON.stringify(value));
}
-function mergePriceAlertEvents(
- incoming: FavoritePriceAlertEvent[],
- existing: FavoritePriceAlertEvent[],
-): FavoritePriceAlertEvent[] {
- if (incoming.length === 0) {
- return existing;
- }
-
- const known = new Set(existing.map((item) => item.id));
- const uniqueIncoming = incoming.filter((item) => !known.has(item.id));
- if (uniqueIncoming.length === 0) {
- return existing;
- }
-
- return [...uniqueIncoming, ...existing]
- .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime())
- .slice(0, PRICE_ALERT_EVENTS_LIMIT);
-}
-
function currentLocalDayKey(date = new Date()): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
@@ -517,10 +495,6 @@ function currentLocalDayKey(date = new Date()): string {
return `${year}-${month}-${day}`;
}
-function cityAlertStorageKey(subscription: CityPriceAlertSubscription): string {
- return `${subscription.key}|${subscription.propertyType}|${subscription.marketType}`;
-}
-
function cityAlertPropertyTypeToApi(propertyType: HistoryPropertyType): ApiPropertyType | null {
if (propertyType === 'Mieszkanie') {
return 'APARTMENT';
@@ -531,86 +505,6 @@ function cityAlertPropertyTypeToApi(propertyType: HistoryPropertyType): ApiPrope
return null;
}
-function cityAlertMarketFilter(marketType: HistoryMarketType): 'wtorny' | 'pierwotny' | null {
- if (marketType === 'Wtórny') {
- return 'wtorny';
- }
- if (marketType === 'Pierwotny') {
- return 'pierwotny';
- }
- return null;
-}
-
-// Ogloszenia pasujace do kryteriow alertu (miasto + typ nieruchomosci + rynek).
-// Wspolne dla sredniej ceny za m² i dla wyboru najlepszej biezacej oferty.
-function filterCityAlertListings(
- subscription: CityPriceAlertSubscription,
- listings: ApiListingSummary[],
-): ApiListingSummary[] {
- const targetPropertyType = cityAlertPropertyTypeToApi(subscription.propertyType);
- const targetMarket = cityAlertMarketFilter(subscription.marketType);
- const cityQuery = subscription.city.trim() || subscription.label.split(',')[0]?.trim() || '';
-
- return listings.filter((item) => {
- if (cityQuery && !cityStartsWith(item.city, cityQuery)) {
- return false;
- }
- if (targetPropertyType && item.propertyType !== targetPropertyType) {
- return false;
- }
- if (targetMarket && item.market !== targetMarket) {
- return false;
- }
- return item.area > 0 && item.price > 0;
- });
-}
-
-// Najlepsza oferta dla alertu: najnizsza cena za m² sposrod pasujacych ogloszen.
-function findBestCityAlertOffer(
- subscription: CityPriceAlertSubscription,
- listings: ApiListingSummary[],
-): ApiListingSummary | null {
- const filtered = filterCityAlertListings(subscription, listings);
- if (filtered.length === 0) {
- return null;
- }
- return filtered.reduce((best, item) => (
- (item.price / item.area) < (best.price / best.area) ? item : best
- ));
-}
-
-// null = brak pasujacych ogloszen, wiec nie ma z czego policzyc sredniej. Wczesniej podstawiana
-// byla szacunkowa stawka z tabeli, przez co znikniecie ogloszen wygladalo jak zmiana ceny.
-function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listings: ApiListingSummary[]): number | null {
- const filtered = filterCityAlertListings(subscription, listings);
-
- if (filtered.length === 0) {
- return null;
- }
-
- const averagePerM2 = filtered.reduce((sum, item) => sum + (item.price / item.area), 0) / filtered.length;
- return Math.max(1, Math.round(averagePerM2));
-}
-
-function mergeCityPriceAlertEvents(
- incoming: CityPriceAlertEvent[],
- existing: CityPriceAlertEvent[],
-): CityPriceAlertEvent[] {
- if (incoming.length === 0) {
- return existing;
- }
-
- const known = new Set(existing.map((item) => item.id));
- const uniqueIncoming = incoming.filter((item) => !known.has(item.id));
- if (uniqueIncoming.length === 0) {
- return existing;
- }
-
- return [...uniqueIncoming, ...existing]
- .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime())
- .slice(0, PRICE_ALERT_EVENTS_LIMIT);
-}
-
const favoriteListings: FavoriteListing[] = [];
function favoriteListingKey(listingId: number): string {
@@ -2719,7 +2613,9 @@ function App() {
const navigationType = useNavigationType();
const [favoriteListingsState, setFavoriteListingsState] = useState(favoriteListings);
const [favoritesPreferredTab, setFavoritesPreferredTab] = useState('buy');
- const [listingPriceAlertSubscriptionsState, setListingPriceAlertSubscriptionsState] = useState([]);
+ // Alerty cenowe sa na koncie - zmiany wykrywa serwer raz dziennie, a wynik laduje
+ // na liscie powiadomien. Przegladarka juz niczego nie monitoruje ani nie liczy.
+ const [priceAlerts, setPriceAlerts] = useState([]);
const [unreadFavoriteIds, setUnreadFavoriteIds] = useState>(new Set());
const hydratedFavoriteIdsRef = useRef>(new Set());
const favoriteListingIdList = useMemo(
@@ -2728,10 +2624,13 @@ function App() {
);
const favoriteListingIdsSignature = useMemo(() => favoriteListingIdList.join(','), [favoriteListingIdList]);
const listingPriceAlertIdList = useMemo(
- () => Array.from(new Set(listingPriceAlertSubscriptionsState.map((item) => item.listingId))).sort((left, right) => left - right),
- [listingPriceAlertSubscriptionsState],
+ () => Array.from(new Set(priceAlerts
+ .filter((alert) => alert.kind === 'LISTING' && alert.listingId != null)
+ .map((alert) => alert.listingId as number))).sort((left, right) => left - right),
+ [priceAlerts],
);
- const listingPriceAlertIdsSignature = useMemo(() => listingPriceAlertIdList.join(','), [listingPriceAlertIdList]);
+ // Ogloszenia, ktorych ceny odswiezamy w tle: ulubione (zeby karty nie pokazywaly starej ceny)
+ // i te z alertem (zeby przycisk alertu byl zgodny ze stanem oferty).
const monitoredListingIdList = useMemo(
() => Array.from(new Set([...favoriteListingIdList, ...listingPriceAlertIdList])).sort((left, right) => left - right),
[favoriteListingIdList, listingPriceAlertIdList],
@@ -2811,7 +2710,6 @@ function App() {
useEffect(() => {
setFavoritesPreferredTab(readUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, 'buy'));
- setListingPriceAlertSubscriptionsState(readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []));
}, [user]);
// Ulubione trzyma konto (/api/listings/favorites), wiec sa te same na kazdym urzadzeniu.
@@ -2849,115 +2747,51 @@ function App() {
writeUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, favoritesPreferredTab);
}, [favoritesPreferredTab, user]);
- useEffect(() => {
- writeUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, listingPriceAlertSubscriptionsState);
- }, [listingPriceAlertSubscriptionsState, user]);
+ const reloadPriceAlerts = useCallback(async () => {
+ if (!user) {
+ setPriceAlerts([]);
+ return [] as ApiPriceAlert[];
+ }
+ try {
+ const items = await apiFetch('/price-alerts');
+ setPriceAlerts(items);
+ return items;
+ } catch {
+ setPriceAlerts([]);
+ return [] as ApiPriceAlert[];
+ }
+ }, [user]);
+ // Alerty zalozone wczesniej w przegladarce przenosimy raz na konto, potem czytamy juz z serwera.
useEffect(() => {
if (!user) {
+ setPriceAlerts([]);
return;
}
-
let cancelled = false;
- let running = false;
-
- // Sprawdzanie stawki za m² dla obserwowanych lokalizacji. Wczesniej bylo zamkniete
- // bramka "raz na dobe" (lastRunKey === todayKey), przez co po zapisaniu pierwszego
- // punktu odniesienia zadna zmiana ceny tego samego dnia nie byla juz wykrywana -
- // alerty lokalizacji praktycznie nie dzialaly. Teraz chodzi cyklicznie, tak jak
- // monitoring cen pojedynczych ogloszen. Zdarzenie i tak powstaje wylacznie wtedy,
- // gdy srednia faktycznie sie zmieni, wiec czestsze sprawdzanie nie generuje szumu.
- const runCityAlertsUpdate = async () => {
- if (cancelled || running) {
- return;
- }
-
- const todayKey = currentLocalDayKey();
- running = true;
- try {
- const subscriptions = readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []);
- if (subscriptions.length === 0) {
- return;
- }
-
- const saleListings = await apiFetch('/listings?offerType=SALE');
- const previousSnapshots = readUserJsonStorage>(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, {});
- const previousEvents = readUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, []);
-
- const nowIso = new Date().toISOString();
- const nextSnapshots: Record = { ...previousSnapshots };
- const incomingEvents: CityPriceAlertEvent[] = [];
-
- subscriptions.forEach((subscription) => {
- const storageKey = cityAlertStorageKey(subscription);
- const currentPerM2 = calculateCityAlertPerM2(subscription, saleListings);
- if (currentPerM2 === null) {
- // Bez ogloszen nie ma sredniej - zostawiamy ostatni pomiar do porownania pozniej.
- return;
- }
- const previousPerM2 = previousSnapshots[storageKey]?.perM2;
-
- if (Number.isFinite(previousPerM2) && previousPerM2 !== currentPerM2) {
- incomingEvents.push({
- id: `${storageKey}:${todayKey}:${previousPerM2}->${currentPerM2}`,
- subscriptionKey: subscription.key,
- label: subscription.label,
- city: subscription.city,
- region: subscription.region,
- propertyType: subscription.propertyType,
- marketType: subscription.marketType,
- previousPerM2,
- currentPerM2,
- changedAt: nowIso,
- });
- }
-
- nextSnapshots[storageKey] = {
- dayKey: todayKey,
- perM2: currentPerM2,
- updatedAt: nowIso,
- };
- });
-
- writeUserJsonStorage(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, nextSnapshots);
- if (incomingEvents.length > 0) {
- const mergedEvents = mergeCityPriceAlertEvents(incomingEvents, previousEvents);
- writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, mergedEvents);
-
- // Powiadomienie tylko o spadku sredniej stawki - tak samo jak przy obserwowanym
- // ogloszeniu. Klucz ma prefiks "city-price--", dzieki czemu
- // usuniecie alertu potrafi posprzatac powiazane powiadomienia.
- incomingEvents.forEach((event) => {
- if (event.currentPerM2 < event.previousPerM2) {
- const diff = event.previousPerM2 - event.currentPerM2;
- notificationsCenter.notify({
- category: 'listing',
- icon: 'coin',
- title: 'Spadek cen w lokalizacji',
- body: `Średnia cena za ${areaSuffix} w „${event.label}” spadła o ${formatPricePerArea(diff)}.`,
- link: 'priceAlerts',
- dedupeKey: `city-price-${event.subscriptionKey}-${event.currentPerM2}`,
- });
- }
- });
- }
- } catch {
- // Keep silent; updater retries on next interval.
- } finally {
- running = false;
- }
- };
-
- void runCityAlertsUpdate();
- const intervalId = window.setInterval(() => {
- void runCityAlertsUpdate();
- }, 60000);
-
- return () => {
- cancelled = true;
- window.clearInterval(intervalId);
- };
- }, [user]);
+ const localListings = readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []);
+ const localCities = readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []);
+ const imports = [
+ ...localListings.map((item) => ({ listingId: item.listingId })),
+ ...localCities.map((item) => ({
+ label: item.label,
+ city: item.city || item.label.split(',')[0]?.trim() || '',
+ region: item.region,
+ propertyType: cityAlertPropertyTypeToApi(item.propertyType) ?? 'APARTMENT',
+ market: HISTORY_MARKET_API[item.marketType] ?? '',
+ })),
+ ];
+ const migrate = imports.length === 0
+ ? Promise.resolve()
+ : Promise.all(imports.map((body) => (
+ apiFetch('/price-alerts', { method: 'POST', body: JSON.stringify(body) }).catch(() => null)
+ ))).then(() => {
+ writeUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []);
+ writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []);
+ });
+ migrate.then(() => { if (!cancelled) { void reloadPriceAlerts(); } });
+ return () => { cancelled = true; };
+ }, [user, reloadPriceAlerts]);
useEffect(() => {
if (location.pathname === ROUTES.favorites) {
@@ -3311,58 +3145,46 @@ function App() {
};
const isListingPriceAlertEnabled = (listingId: number) => (
- listingPriceAlertSubscriptionsState.some((item) => item.listingId === listingId)
+ priceAlerts.some((alert) => alert.kind === 'LISTING' && alert.listingId === listingId)
);
const toggleListingPriceAlert = (listing: ApiListingDetail) => {
- const listingLocation = [listing.city, listing.district].filter(Boolean).join(', ');
- setListingPriceAlertSubscriptionsState((current) => {
- const exists = current.some((item) => item.listingId === listing.id);
- if (exists) {
- return current.filter((item) => item.listingId !== listing.id);
- }
- const next: ListingPriceAlertSubscription = {
- listingId: listing.id,
- title: listing.title,
- location: listingLocation || listing.city,
- offerType: listing.offerType,
- createdAt: new Date().toISOString(),
- };
- return [next, ...current];
- });
+ const existing = priceAlerts.find((alert) => alert.kind === 'LISTING' && alert.listingId === listing.id);
+ if (existing) {
+ removeListingPriceAlert(listing.id);
+ return;
+ }
+ apiFetch('/price-alerts', { method: 'POST', body: JSON.stringify({ listingId: listing.id }) })
+ .then((alert) => setPriceAlerts((current) => (
+ current.some((item) => item.id === alert.id) ? current : [alert, ...current]
+ )))
+ .catch(() => { /* brak sieci - alert nie powstal, uzytkownik moze sprobowac ponownie */ });
};
- // Usuniecie i przelaczanie powiadomien z poziomu /konto/alerty-cenowe. Stan subskrypcji
- // ogloszen zyje tutaj (efekt nizej zapisuje go do localStorage), wiec strona alertow
- // modyfikuje go przez te funkcje zamiast pisac po magazynie na wlasna reke.
- // Usuniecie alertu kasuje caly slad po nim: subskrypcje, zarejestrowane zmiany ceny,
- // punkt odniesienia oraz powiadomienia w centrum - tak, jakby alertu nigdy nie bylo.
+ // Usuniecie alertu kasuje caly slad po nim: alert i jego historie na serwerze oraz
+ // powiadomienia w centrum - tak, jakby alertu nigdy nie bylo.
const removeListingPriceAlert = (listingId: number) => {
- setListingPriceAlertSubscriptionsState((current) => current.filter((item) => item.listingId !== listingId));
-
- if (user) {
- const events = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []);
- writeUserJsonStorage(
- user,
- FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX,
- events.filter((event) => event.listingId !== listingId),
- );
-
- const snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {});
- delete snapshots[String(listingId)];
- writeUserJsonStorage(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, snapshots);
+ const alert = priceAlerts.find((item) => item.kind === 'LISTING' && item.listingId === listingId);
+ if (!alert) {
+ return;
}
-
- // Powiadomienia o zmianie ceny tego ogloszenia maja klucz "price--".
- notificationsCenter.dismissByDedupePrefix(`price-${listingId}-`);
+ setPriceAlerts((current) => current.filter((item) => item.id !== alert.id));
+ apiFetch(`/price-alerts/${alert.id}`, { method: 'DELETE' })
+ .catch(() => { void reloadPriceAlerts(); });
+ notificationsCenter.dismissByDedupePrefix(`price-alert:${alert.id}:`);
};
const toggleListingPriceAlertNotifications = (listingId: number) => {
- setListingPriceAlertSubscriptionsState((current) => current.map((item) => (
- item.listingId === listingId
- ? { ...item, notificationsEnabled: item.notificationsEnabled === false }
- : item
+ const alert = priceAlerts.find((item) => item.kind === 'LISTING' && item.listingId === listingId);
+ if (!alert) {
+ return;
+ }
+ const enabled = !alert.notificationsEnabled;
+ setPriceAlerts((current) => current.map((item) => (
+ item.id === alert.id ? { ...item, notificationsEnabled: enabled } : item
)));
+ apiFetch(`/price-alerts/${alert.id}/notifications`, { method: 'PUT', body: JSON.stringify({ enabled }) })
+ .catch(() => { void reloadPriceAlerts(); });
};
const toggleSummaryFavorite = async (listing: ApiListingSummary) => {
@@ -3469,14 +3291,14 @@ function App() {
};
}, [favoriteListingsState]);
+ // Odswiezanie cen obserwowanych ogloszen - tylko po to, zeby karty ulubionych nie pokazywaly
+ // nieaktualnej kwoty. Zmiany cen dla alertow wykrywa serwer, wiec nic tu nie liczymy.
useEffect(() => {
if (!user || monitoredListingIdList.length === 0) {
return;
}
let cancelled = false;
- let snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {});
- const alertListingIdSet = new Set(listingPriceAlertIdList);
const syncFavoritePrices = async () => {
const refreshed = await Promise.all(
@@ -3493,64 +3315,13 @@ function App() {
return;
}
- const nowIso = new Date().toISOString();
- const incomingEvents: FavoritePriceAlertEvent[] = [];
- const nextSnapshots = { ...snapshots };
const refreshedFavorites = new Map();
-
refreshed.forEach((listing) => {
- if (!listing) {
- return;
+ if (listing) {
+ refreshedFavorites.set(listing.id, toFavoriteListing(listing));
}
-
- const listingIdKey = String(listing.id);
- const previousPrice = nextSnapshots[listingIdKey];
- const currentPrice = Number(listing.price);
- if (Number.isFinite(currentPrice)) {
- if (alertListingIdSet.has(listing.id) && Number.isFinite(previousPrice) && previousPrice !== currentPrice) {
- const location = [listing.city, listing.district].filter(Boolean).join(', ');
- incomingEvents.push({
- id: `${listing.id}:${previousPrice}->${currentPrice}`,
- listingId: listing.id,
- title: listing.title,
- location: location || listing.city,
- area: `${`${listing.area}`.replace('.', ',')} m²`,
- offerType: listing.offerType,
- previousPrice,
- currentPrice,
- changedAt: nowIso,
- });
- }
- nextSnapshots[listingIdKey] = currentPrice;
- }
-
- refreshedFavorites.set(listing.id, toFavoriteListing(listing));
});
- snapshots = nextSnapshots;
- writeUserJsonStorage(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, nextSnapshots);
-
- if (incomingEvents.length > 0) {
- const existingEvents = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []);
- const merged = mergePriceAlertEvents(incomingEvents, existingEvents);
- writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged);
-
- // Powiadomienie tylko o spadku ceny obserwowanego ogloszenia.
- incomingEvents.forEach((event) => {
- if (event.currentPrice < event.previousPrice) {
- const diff = event.previousPrice - event.currentPrice;
- notificationsCenter.notify({
- category: 'listing',
- icon: 'coin',
- title: 'Zmiana ceny',
- body: `Cena „${event.title}” spadła o ${formatPrice(diff)}.`,
- link: `listing:${event.listingId}`,
- dedupeKey: `price-${event.listingId}-${event.currentPrice}`,
- });
- }
- });
- }
-
if (refreshedFavorites.size > 0) {
setFavoriteListingsState((current) => current.map((item) => {
const refreshedItem = refreshedFavorites.get(item.listingId);
@@ -3576,7 +3347,7 @@ function App() {
cancelled = true;
window.clearInterval(intervalId);
};
- }, [user, monitoredListingIdList, monitoredListingIdsSignature, listingPriceAlertIdList, listingPriceAlertIdsSignature]);
+ }, [user, monitoredListingIdList, monitoredListingIdsSignature]);
// Wejscie na nowa trase zaczynamy od gory strony. Przy cofaniu (POP) zostawiamy
// pozycje przegladarce, zeby powrot do listy ofert wracal w to samo miejsce.
@@ -3692,7 +3463,8 @@ function App() {
@@ -6550,10 +6322,18 @@ function AccountSidebar() {
}, [user, location.pathname]);
// Licznik obejmuje oba rodzaje alertow - lokalizacji i pojedynczych ogloszen -
// dokladnie tak, jak lista na /konto/alerty-cenowe.
- const priceAlertsCount = user
- ? readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []).length
- + readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []).length
- : 0;
+ const [priceAlertsCount, setPriceAlertsCount] = useState(0);
+ useEffect(() => {
+ if (!user) {
+ setPriceAlertsCount(0);
+ return;
+ }
+ let cancelled = false;
+ apiFetch('/price-alerts')
+ .then((items) => { if (!cancelled) { setPriceAlertsCount(items.length); } })
+ .catch(() => { if (!cancelled) { setPriceAlertsCount(0); } });
+ return () => { cancelled = true; };
+ }, [user, location.pathname]);
useEffect(() => {
if (!user) {
@@ -6687,26 +6467,39 @@ function AccountDashboardPage({
// Realne dane panelu: ostatnio oglądane oferty (localStorage) i alerty cenowe użytkownika
// (to samo źródło co strona /konto/alerty-cenowe).
const [recentlyViewed, setRecentlyViewed] = useState(() => readRecentlyViewed(user));
- const [cityAlerts, setCityAlerts] = useState(
- () => readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []),
- );
+ const [cityAlerts, setCityAlerts] = useState([]);
// Po zmianie konta wczytujemy dane właściwego użytkownika.
useEffect(() => {
setRecentlyViewed(readRecentlyViewed(user));
- setCityAlerts(readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []));
+ if (!user) {
+ setCityAlerts([]);
+ return;
+ }
+ let cancelled = false;
+ apiFetch('/price-alerts')
+ .then((items) => { if (!cancelled) { setCityAlerts(items.filter((item) => item.kind === 'LOCATION')); } })
+ .catch(() => { if (!cancelled) { setCityAlerts([]); } });
+ return () => { cancelled = true; };
}, [user]);
- // Przełącznik powiadomień alertu - zapis trafia do tego samego miejsca co na stronie alertów,
- // więc zmiana jest spójna w całej aplikacji. Brak wartości traktujemy jako "włączone".
- const toggleCityAlert = (key: string) => {
- setCityAlerts((current) => {
- const next = current.map((alert) => (alert.key === key
- ? { ...alert, notificationsEnabled: alert.notificationsEnabled === false }
- : alert));
- writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next);
- return next;
- });
+ // Przełącznik powiadomień alertu - zapis idzie na konto, więc zmiana jest spójna
+ // z tym, co pokazuje strona alertów i co widzi serwer przy codziennym przeglądzie.
+ const toggleCityAlert = (alertId: number) => {
+ const target = cityAlerts.find((alert) => alert.id === alertId);
+ if (!target) {
+ return;
+ }
+ const enabled = !target.notificationsEnabled;
+ setCityAlerts((current) => current.map((alert) => (
+ alert.id === alertId ? { ...alert, notificationsEnabled: enabled } : alert
+ )));
+ apiFetch(`/price-alerts/${alertId}/notifications`, {
+ method: 'PUT',
+ body: JSON.stringify({ enabled }),
+ }).catch(() => setCityAlerts((current) => current.map((alert) => (
+ alert.id === alertId ? { ...alert, notificationsEnabled: !enabled } : alert
+ ))));
};
const tools = [
@@ -6883,20 +6676,20 @@ function AccountDashboardPage({
{renderWidgetHead('alerts', Zarządzaj alertami )}