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 )}
{cityAlerts.slice(0, 3).map((alert) => { - const enabled = alert.notificationsEnabled !== false; + const enabled = alert.notificationsEnabled; return ( -
+
{alert.label} - {alert.propertyType} • {alert.marketType} + {alert.propertyType === 'HOUSE' ? 'Dom' : 'Mieszkanie'} • {alert.market && alert.market.length > 0 ? alert.market : 'Wtórny i pierwotny'}
); @@ -7888,7 +7681,7 @@ function PriceAlertRow({ > - {row.notificationsEnabled ? 'e-mail, push' : 'wyłączone'} + {row.notificationsEnabled ? 'powiadomienia w serwisie' : 'wyłączone'}
@@ -8054,55 +7847,43 @@ function PriceAlertLocationCard({ > - {card.notificationsEnabled ? 'e-mail, push' : 'Powiadomienia wyłączone'} + {card.notificationsEnabled ? 'Powiadomienia w serwisie' : 'Powiadomienia wyłączone'}
); } function AccountPriceAlertsPage({ - user, onOpenListing, - listingAlerts, + alerts, + onReloadAlerts, onRemoveListingAlert, onToggleListingAlertNotifications, }: { user: AuthUser | null; onOpenListing: (id: number) => void; - listingAlerts: ListingPriceAlertSubscription[]; + alerts: ApiPriceAlert[]; + onReloadAlerts: () => Promise; onRemoveListingAlert: (listingId: number) => void; onToggleListingAlertNotifications: (listingId: number) => void; }) { const { dismissByDedupePrefix } = useNotifications(); const { formatPrice, formatPricePerArea, formatArea, areaSuffix } = useDisplay(); - const [cityAlerts, setCityAlerts] = useState([]); - const [priceEvents, setPriceEvents] = useState([]); - const [cityPriceEvents, setCityPriceEvents] = useState([]); + const [events, setEvents] = useState([]); const [listings, setListings] = useState([]); const [trendPeriod, setTrendPeriod] = useState('12'); const [openMenuKey, setOpenMenuKey] = useState(null); - // Alerty lokalizacji i zdarzenia cenowe dopisuje w tle glowny komponent App, wiec widok - // co 10 s siega po aktualny stan zapisany w localStorage uzytkownika. Alerty pojedynczych - // ogloszen przychodza propsem - ich zrodlem prawdy jest stan w App. + // Zmiany cen rejestruje serwer (codzienny przeglad alertow) - tutaj tylko je czytamy. + const reloadEvents = useCallback(() => ( + apiFetch(`/price-alerts/events?days=${PRICE_ALERT_STATS_DAYS}`) + .then(setEvents) + .catch(() => setEvents([])) + ), []); + useEffect(() => { - if (!user) { - setCityAlerts([]); - setPriceEvents([]); - setCityPriceEvents([]); - return; - } - - const hydrate = () => { - setCityAlerts(readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, [])); - setPriceEvents(readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, [])); - setCityPriceEvents(readUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, [])); - }; - - hydrate(); - const intervalId = window.setInterval(hydrate, 10000); - return () => window.clearInterval(intervalId); - }, [user]); + void reloadEvents(); + }, [reloadEvents]); // Ogloszenia z API daja aktualna cene i zdjecie dla alertow zalozonych na ofertach. useEffect(() => { @@ -8133,112 +7914,86 @@ function AccountPriceAlertsPage({ return () => window.removeEventListener('click', close); }, [openMenuKey]); - const persistCityAlerts = (next: CityPriceAlertSubscription[]) => { - if (user) { - writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next); - } - setCityAlerts(next); - }; + const listingAlerts = useMemo(() => alerts.filter((alert) => alert.kind === 'LISTING'), [alerts]); + const cityAlerts = useMemo(() => alerts.filter((alert) => alert.kind === 'LOCATION'), [alerts]); - // Jak przy alertach ogloszen: kasujemy tez zarejestrowane zmiany i punkt odniesienia, - // zeby po usunieciu alertu nie zostal po nim slad w historii zmian. - const removeCityAlert = (key: string) => { - persistCityAlerts(cityAlerts.filter((item) => item.key !== key)); - - if (user) { - const remainingEvents = cityPriceEvents.filter((event) => event.subscriptionKey !== key); - writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, remainingEvents); - setCityPriceEvents(remainingEvents); - - // Klucz snapshotu to "||", wiec zdejmujemy wszystkie warianty tej lokalizacji. - const snapshots = readUserJsonStorage>( - user, - CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, - {}, - ); - Object.keys(snapshots) - .filter((snapshotKey) => snapshotKey === key || snapshotKey.startsWith(`${key}|`)) - .forEach((snapshotKey) => delete snapshots[snapshotKey]); - writeUserJsonStorage(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, snapshots); - } - - // Powiadomienia o spadkach w tej lokalizacji maja klucz "city-price--". - dismissByDedupePrefix(`city-price-${key}-`); + const removeAlert = (alert: ApiPriceAlert) => { + apiFetch(`/price-alerts/${alert.id}`, { method: 'DELETE' }) + .then(() => Promise.all([onReloadAlerts(), reloadEvents()])) + .catch(() => { /* brak sieci - alert zostaje, uzytkownik moze sprobowac ponownie */ }); + // Powiadomienia tego alertu maja klucz "price-alert::...". + dismissByDedupePrefix(`price-alert:${alert.id}:`); setOpenMenuKey(null); }; - const toggleCityAlertNotifications = (key: string) => { - persistCityAlerts(cityAlerts.map((item) => ( - item.key === key ? { ...item, notificationsEnabled: item.notificationsEnabled === false } : item - ))); + const toggleAlertNotifications = (alert: ApiPriceAlert) => { + apiFetch(`/price-alerts/${alert.id}/notifications`, { + method: 'PUT', + body: JSON.stringify({ enabled: !alert.notificationsEnabled }), + }) + .then(() => onReloadAlerts()) + .catch(() => { /* brak sieci - przelacznik wroci do stanu z serwera */ }); }; const chartMonths = PRICE_ALERT_TREND_PERIODS.find((item) => item.key === trendPeriod)?.months ?? 12; const chartLabels = useMemo(() => priceAlertTrendLabels(chartMonths), [chartMonths]); - // Alerty lokalizacji sledza srednia cene ofertowa mieszkan NA SPRZEDAZ - taki sam - // zbior, na jakim pracuje monitoring w tle (/listings?offerType=SALE). - const saleListings = useMemo(() => listings.filter((item) => item.offerType === 'SALE'), [listings]); + const eventsForAlert = (alertId: number) => events.filter((event) => event.alertId === alertId); // --- Alerty lokalizacji (dodane w historii cen) jako karty z wykresem --- const locationCards: PriceAlertLocationCardModel[] = useMemo(() => cityAlerts - .map((subscription) => { - // Stawka liczona z realnych ogloszen na sprzedaz - dokladnie ta sama, ktora - // porownuje monitoring w tle, wiec naglowek karty zgadza sie z zarejestrowana zmiana. - const currentPerM2 = calculateCityAlertPerM2(subscription, saleListings); - // Wykres tylko z zarejestrowanych zmian (zdarzenia sa od najnowszego) - bez nich nie rysujemy nic. - const subscriptionEvents = cityPriceEvents.filter((event) => event.subscriptionKey === subscription.key); + .map((alert) => { + const propertyLabel = alert.propertyType === 'HOUSE' ? 'Dom' : 'Mieszkanie'; + const marketLabel = alert.market && alert.market.length > 0 ? alert.market : 'Wtórny i pierwotny'; + // Ostatni pomiar serwera; wykres rysujemy z zarejestrowanych zmian (od najnowszej). + const currentPerM2 = alert.lastValue; + const alertEvents = eventsForAlert(alert.id); const series = currentPerM2 === null ? [] - : [...subscriptionEvents].reverse().map((event) => event.previousPerM2).concat(currentPerM2); + : [...alertEvents].reverse().map((event) => event.previousValue).concat(currentPerM2); - const lastEvent = subscriptionEvents[0] ?? null; - const eventAmount = lastEvent ? lastEvent.currentPerM2 - lastEvent.previousPerM2 : 0; + const lastEvent = alertEvents[0] ?? null; + const eventAmount = lastEvent ? lastEvent.currentValue - lastEvent.previousValue : 0; return { - key: subscription.key, - // Nazwa wybrana przez uzytkownika w historii cen. Dopasowany rynek sluzy tylko - // do wyliczenia serii - przy nazwie spoza slownika potrafi wskazac inna dzielnice. - label: subscription.label, - meta: `${subscription.propertyType} • ${subscription.marketType}`, - historyTo: priceHistoryPath(subscription.label, subscription.propertyType, subscription.marketType), + key: `alert-${alert.id}`, + label: alert.label, + meta: `${propertyLabel} • ${marketLabel}`, + historyTo: priceHistoryPath(alert.label, propertyLabel, marketLabel), chart: series.length >= 2 ? buildPriceAlertChart(series) : null, currentPerM2, - // Tylko zmiana zarejestrowana przez alert - bez niej nie pokazujemy zadnego procentu. - changePercent: lastEvent - ? priceAlertChangePercent(lastEvent.previousPerM2, lastEvent.currentPerM2) - : 0, + changePercent: lastEvent ? priceAlertChangePercent(lastEvent.previousValue, lastEvent.currentValue) : 0, changeIsReal: Boolean(lastEvent), changeCaption: lastEvent ? `${formatPricePerArea(Math.abs(eventAmount))} ${eventAmount <= 0 ? 'taniej' : 'drożej'} • ${priceAlertRelativeTime(lastEvent.changedAt)}` : currentPerM2 === null ? 'Brak ogłoszeń w tej lokalizacji' : 'Brak zarejestrowanych zmian', - notificationsEnabled: subscription.notificationsEnabled !== false, - onToggle: () => toggleCityAlertNotifications(subscription.key), - onRemove: () => removeCityAlert(subscription.key), + notificationsEnabled: alert.notificationsEnabled, + onToggle: () => toggleAlertNotifications(alert), + onRemove: () => removeAlert(alert), }; - }), [cityAlerts, cityPriceEvents, chartMonths, saleListings]); + }), [cityAlerts, events, chartMonths]); // --- Alerty pojedynczych ogloszen ("Utworz alert ceny" na ofercie) --- const listingRowModels: PriceAlertRowModel[] = useMemo(() => listingAlerts - .map((subscription) => { - const listing = listings.find((item) => item.id === subscription.listingId) ?? null; - const lastEvent = priceEvents.find((event) => event.listingId === subscription.listingId) ?? null; - const changeAmount = lastEvent ? lastEvent.currentPrice - lastEvent.previousPrice : 0; + .map((alert) => { + const listing = listings.find((item) => item.id === alert.listingId) ?? null; + const lastEvent = eventsForAlert(alert.id)[0] ?? null; + const changeAmount = lastEvent ? lastEvent.currentValue - lastEvent.previousValue : 0; return { - id: `listing:${subscription.listingId}`, - createdAt: subscription.createdAt, + id: `alert-${alert.id}`, + createdAt: alert.createdAt, thumb: listing?.coverPhoto ?? null, - title: listing?.title ?? subscription.title, + title: listing?.title ?? alert.label, // Dane z API maja pierwszenstwo - ogloszenie moglo zmienic miasto albo typ oferty - // po zalozeniu alertu. Zapis w subskrypcji sluzy tylko jako awaryjny opis. + // po zalozeniu alertu. Etykieta alertu sluzy tylko jako awaryjny opis. meta: listing ? `${listing.offerType === 'RENT' ? 'Wynajem' : 'Sprzedaż'} • ${[listing.city, listing.district].filter(Boolean).join(', ')}` - : `${subscription.offerType === 'RENT' ? 'Wynajem' : 'Sprzedaż'} • ${subscription.location}`, + : 'Ogłoszenie nie jest teraz dostępne', subMeta: listing ? `Cena za ${areaSuffix}: ${formatPricePerArea(listing.price / Math.max(1, listing.area))}` : 'Ogłoszenie nie jest teraz dostępne', hasChange: Boolean(lastEvent), - changePercent: lastEvent ? priceAlertChangePercent(lastEvent.previousPrice, lastEvent.currentPrice) : 0, + changePercent: lastEvent ? priceAlertChangePercent(lastEvent.previousValue, lastEvent.currentValue) : 0, changeLabel: lastEvent ? `${formatPrice(Math.abs(changeAmount))} ${changeAmount <= 0 ? 'taniej' : 'drożej'}` : 'Czeka na pierwszą zmianę', @@ -8251,40 +8006,27 @@ function AccountPriceAlertsPage({ listingRelativeDate(listing.createdAt), ] : ['Oferta została zdjęta lub wstrzymana'], - notificationsEnabled: subscription.notificationsEnabled !== false, - ctaTo: listingPath(subscription.listingId), + notificationsEnabled: alert.notificationsEnabled, + ctaTo: listingPath(alert.listingId ?? 0), ctaLabel: 'Zobacz ofertę', - onToggle: () => onToggleListingAlertNotifications(subscription.listingId), + onToggle: () => onToggleListingAlertNotifications(alert.listingId ?? 0), onRemove: () => { - onRemoveListingAlert(subscription.listingId); - // Magazyn sprzata App; tutaj zdejmujemy zdarzenia od razu, zeby feed - // nie pokazywal ich jeszcze przez chwile do najblizszego odswiezenia. - setPriceEvents((current) => current.filter((event) => event.listingId !== subscription.listingId)); + onRemoveListingAlert(alert.listingId ?? 0); + void reloadEvents(); setOpenMenuKey(null); }, }; }) .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()), - [listingAlerts, listings, priceEvents, onRemoveListingAlert, onToggleListingAlertNotifications]); + [listingAlerts, listings, events, onRemoveListingAlert, onToggleListingAlertNotifications]); - const alertsCount = cityAlerts.length + listingAlerts.length; + const alertsCount = alerts.length; // Zmiany cen z ostatnich 30 dni - wspolne zrodlo dla kafli podsumowania. - const recentChanges = useMemo(() => { - const cutoff = Date.now() - (PRICE_ALERT_STATS_DAYS * 24 * 60 * 60 * 1000); - const withinWindow = (iso: string) => { - const time = new Date(iso).getTime(); - return !Number.isNaN(time) && time >= cutoff; - }; - return [ - ...priceEvents - .filter((event) => withinWindow(event.changedAt)) - .map((event) => priceAlertChangePercent(event.previousPrice, event.currentPrice)), - ...cityPriceEvents - .filter((event) => withinWindow(event.changedAt)) - .map((event) => priceAlertChangePercent(event.previousPerM2, event.currentPerM2)), - ]; - }, [priceEvents, cityPriceEvents]); + const recentChanges = useMemo( + () => events.map((event) => priceAlertChangePercent(event.previousValue, event.currentValue)), + [events], + ); const dropCount = recentChanges.filter((value) => value < 0).length; const riseCount = recentChanges.filter((value) => value > 0).length; @@ -8292,25 +8034,16 @@ function AccountPriceAlertsPage({ ? recentChanges.reduce((sum, value) => sum + value, 0) / recentChanges.length : 0; - const notificationFeed = useMemo(() => ([ - ...priceEvents.map((event) => ({ - id: event.id, + const notificationFeed = useMemo(() => events + .map((event) => ({ + id: String(event.id), changedAt: event.changedAt, - percent: priceAlertChangePercent(event.previousPrice, event.currentPrice), - title: event.title, - listingId: event.listingId as number | null, - })), - ...cityPriceEvents.map((event) => ({ - id: event.id, - changedAt: event.changedAt, - percent: priceAlertChangePercent(event.previousPerM2, event.currentPerM2), + percent: priceAlertChangePercent(event.previousValue, event.currentValue), title: event.label, - listingId: null as number | null, - })), - ]) + listingId: event.listingId, + })) .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()) - .slice(0, PRICE_ALERT_FEED_LIMIT), [priceEvents, cityPriceEvents]); - + .slice(0, PRICE_ALERT_FEED_LIMIT), [events]); return (
@@ -8324,7 +8057,7 @@ function AccountPriceAlertsPage({ Alerty cenowe {alertsCount} -

Śledź zmiany cen mieszkań i bądź na bieżąco. Otrzymuj powiadomienia, gdy ceny spadną lub wzrosną.

+

Śledź zmiany cen mieszkań i bądź na bieżąco. Powiadomienie o zmianie znajdziesz na swojej liście powiadomień - nie wysyłamy maili.

@@ -11882,7 +11615,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences { /* funkcja jeszcze nie powstala */ }} @@ -21514,7 +21247,8 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const [marketType, setMarketType] = useState('Wtórny i pierwotny'); const [period, setPeriod] = useState('5y'); const [createAlertFeedback, setCreateAlertFeedback] = useState(null); - const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState([]); + // Alerty lokalizacji sa na koncie - tu tylko sprawdzamy, czy ta lokalizacja jest juz obserwowana. + const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState([]); const [suggestions, setSuggestions] = useState([]); const [suggestOpen, setSuggestOpen] = useState(false); const [suggestLoading, setSuggestLoading] = useState(false); @@ -21668,7 +21402,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const tooltipX = hoveredPoint ? Math.min(chartWidth - tooltipWidth - 8, Math.max(8, hoveredPoint.x - tooltipWidth / 2)) : 0; const tooltipY = hoveredPoint ? Math.max(8, hoveredPoint.y - tooltipHeight - 14) : 0; const selectedMarketAlreadyAlerted = selectedMarket - ? cityAlertSubscriptions.some((item) => item.key === selectedMarket.key) + ? cityAlertSubscriptions.some((item) => item.label === selectedMarket.label) : false; // Dymek przy najblizszym punkcie z danymi. @@ -21737,7 +21471,9 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { setCityAlertSubscriptions([]); return; } - setCityAlertSubscriptions(readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, [])); + apiFetch('/price-alerts') + .then((items) => setCityAlertSubscriptions(items.filter((item) => item.kind === 'LOCATION'))) + .catch(() => setCityAlertSubscriptions([])); }, [user]); useEffect(() => { @@ -21862,26 +21598,29 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { return; } - const subscription: CityPriceAlertSubscription = { - key: selectedMarket.key, - label: selectedMarket.label, - city: selectedMarket.city, - region: selectedMarket.region, - propertyType, - marketType, - createdAt: new Date().toISOString(), - }; + if (cityAlertSubscriptions.some((item) => item.label === selectedMarket.label)) { + setCreateAlertFeedback('Ten alert jest już aktywny.'); + return; + } - setCityAlertSubscriptions((current) => { - if (current.some((item) => item.key === subscription.key)) { - setCreateAlertFeedback('Ten alert jest już aktywny.'); - return current; - } - const next = [subscription, ...current]; - writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next); - setCreateAlertFeedback('Alert cenowy został dodany. Znajdziesz go w panelu użytkownika.'); - return next; - }); + // Alert zaklada serwer - to on raz dziennie sprawdza srednia cene i wysyla powiadomienie. + apiFetch('/price-alerts', { + method: 'POST', + body: JSON.stringify({ + label: selectedMarket.label, + city: selectedMarket.city, + region: selectedMarket.region, + propertyType: cityAlertPropertyTypeToApi(propertyType) ?? 'APARTMENT', + market: HISTORY_MARKET_API[marketType] ?? '', + }), + }) + .then((alert) => { + setCityAlertSubscriptions((current) => ( + current.some((item) => item.id === alert.id) ? current : [alert, ...current] + )); + setCreateAlertFeedback('Alert cenowy został dodany. Znajdziesz go w panelu użytkownika.'); + }) + .catch((error: Error) => setCreateAlertFeedback(error.message || 'Nie udało się dodać alertu.')); }; // Alert da sie usunac tam, gdzie zostal dodany - bez wchodzenia w zakladke alertow cenowych. @@ -21889,12 +21628,14 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { if (!user || !selectedMarket) { return; } - setCityAlertSubscriptions((current) => { - const next = current.filter((item) => item.key !== selectedMarket.key); - writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next); - setCreateAlertFeedback(`Alert cenowy dla lokalizacji ${selectedMarket.label} został usunięty.`); - return next; - }); + const alert = cityAlertSubscriptions.find((item) => item.label === selectedMarket.label); + if (!alert) { + return; + } + setCityAlertSubscriptions((current) => current.filter((item) => item.id !== alert.id)); + setCreateAlertFeedback(`Alert cenowy dla lokalizacji ${selectedMarket.label} został usunięty.`); + apiFetch(`/price-alerts/${alert.id}`, { method: 'DELETE' }) + .catch(() => setCreateAlertFeedback('Nie udało się usunąć alertu. Spróbuj ponownie.')); }; return ( diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 71498b1..18df142 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -620,3 +620,38 @@ test.describe('Historia cen', () => { await expect(page.locator('main')).toContainText('Kraków'); }); }); + +// Alerty cenowe sa na koncie: zmiany wykrywa serwer, a powiadomienie trafia na liste w serwisie. +test.describe('Alerty cenowe', () => { + test('alert ogloszenia przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => { + await login(page); + await goto(page, ROUTES.buy); + await page.locator('.real-result-card').first().waitFor(); + const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); + await goto(page, href!); + + const alertButton = page.locator('.listing-detail-alert'); + if ((await alertButton.innerText()).includes('aktywny')) { + await alertButton.click(); + await expect(alertButton).toHaveText(/Utwórz alert ceny/); + } + await alertButton.click(); + await expect(alertButton).toHaveText(/Alert aktywny/); + + // Symulacja innego urzadzenia: zostaje sam token logowania. + await page.evaluate(() => { + const token = window.localStorage.getItem('polskalokalnie-auth-token'); + window.localStorage.clear(); + if (token) { + window.localStorage.setItem('polskalokalnie-auth-token', token); + } + }); + await goto(page, ROUTES.accountPriceAlerts); + await expect(page.locator('.price-alerts-list-section')).not.toContainText('Nie obserwujesz jeszcze żadnego ogłoszenia'); + + // Sprzatanie: alert zdejmujemy tam, gdzie zostal zalozony. + await goto(page, href!); + await page.locator('.listing-detail-alert').click(); + await expect(page.locator('.listing-detail-alert')).toHaveText(/Utwórz alert ceny/); + }); +});