Historia cen na prawdziwych danych: ogloszenia, NBP i GUS
Deploy Polska Lokalnie / deploy (push) Successful in 12s

Strona /historia-cen pokazywala pusty wykres (wczesniej zmyslony). Teraz:

Ceny ofertowe z ogloszen:
- Tabela price_snapshots: codzienny zapis srednich cen za m2 z opublikowanych
  ogloszen per miasto i dzielnica, typ (mieszkanie/dom) i rynek. Zapis co noc
  (02:30) i przy starcie, gdy brakuje dzisiejszego. Historii sprzed pierwszego
  zapisu nie odtwarzamy.

Ceny transakcyjne ze zrodel publicznych (porownanie, cale miasto, mieszkania):
- NBP: plik ceny_mieszkan.xlsx (baza BaRN) - 17 miast, rynek pierwotny
  i wtorny, od III kw. 2006. Odczyt XLSX bez nowych zaleznosci.
- GUS: API Banku Danych Lokalnych (temat P3794) - miasta na prawach powiatu,
  kwartalnie od 2010, z opoznieniem ok. roku.
- Import raz w tygodniu i przy starcie, gdy dane sa starsze niz tydzien;
  blad zrodla nie kasuje poprzednich danych.
- GET /api/price-history (publiczny): historia, biezaca srednia z ogloszen,
  od kiedy zbieramy dane oraz serie NBP i GUS.

Frontend:
- Okresy "Ostatnie 5 lat" (domyslny), "Od 2006 (NBP)", "2026 do dzis";
  trzy serie z legenda, dymek ze wszystkimi zrodlami, statystyki i wnioski
  z prawdziwych liczb, porownanie cen ofertowych z NBP i GUS oraz roznica
  i srednia, gdy zrodla sie roznia. Ceny w "Porownaj lokalizacje".
- Poprawione dopasowanie lokalizacji ("Warszawa" otwierala "Warszawa,
  Mokotów"), usuniete resztki zmyslonych cen i opcja "Lokal uzytkowy".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 13:56:26 +02:00
parent e7402dbfed
commit e92c68ec93
16 changed files with 1722 additions and 212 deletions
@@ -49,6 +49,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
@@ -0,0 +1,166 @@
package pl.polskalokalnie.pricehistory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
/**
* Import cen transakcyjnych z NBP (plik XLSX) i GUS (API BDL) - zrodla porownawcze dla historii cen.
* Raz w tygodniu oraz przy starcie, gdy dane sa starsze niz tydzien. Blad zrodla nie rusza starych danych.
*/
@Service
public class ExternalPriceImportService {
static final String NBP_URL = "https://static.nbp.pl/dane/rynek-nieruchomosci/ceny_mieszkan.xlsx";
private static final long MAX_DOWNLOAD_BYTES = 20L * 1024 * 1024;
private static final int GUS_FROM_YEAR = 2010;
private static final Logger log = LoggerFactory.getLogger(ExternalPriceImportService.class);
private final ExternalQuarterPriceRepository repository;
private final ListingRepository listingRepository;
private final GusBdlClient gusClient;
private final TransactionTemplate transaction;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ExternalPriceImportService(ExternalQuarterPriceRepository repository, ListingRepository listingRepository,
GusBdlClient gusClient, TransactionTemplate transaction) {
this.repository = repository;
this.listingRepository = listingRepository;
this.gusClient = gusClient;
this.transaction = transaction;
}
@Async
@EventListener(ApplicationReadyEvent.class)
public void importOnStartupIfStale() {
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
if (repository.findLastImport(Source.NBP).map(last -> last.isBefore(weekAgo)).orElse(true)) {
importNbp();
}
if (repository.findLastImport(Source.GUS).map(last -> last.isBefore(weekAgo)).orElse(true)) {
importGus();
}
}
@Scheduled(cron = "0 10 4 * * MON", zone = "Europe/Warsaw")
public void importWeekly() {
importNbp();
importGus();
}
public int importNbp() {
try {
List<NbpXlsxParser.Row> rows;
try (InputStream xlsx = download(NBP_URL)) {
rows = NbpXlsxParser.parse(xlsx);
}
if (rows.isEmpty()) {
log.warn("NBP: w pliku nie znaleziono cen transakcyjnych - zostawiam poprzednie dane");
return 0;
}
Instant now = Instant.now();
transaction.executeWithoutResult(status -> {
repository.deleteBySource(Source.NBP);
repository.saveAll(rows.stream()
.map(row -> new ExternalQuarterPrice(Source.NBP, row.year(), row.quarter(), row.market(),
row.city(), row.pricePerM2(), now))
.toList());
});
log.info("NBP: zaimportowano {} cen kwartalnych", rows.size());
return rows.size();
} catch (IOException | RuntimeException ex) {
log.warn("NBP: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
return 0;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return 0;
}
}
/** GUS dla miast z ogloszen i miast z danych NBP - tylko miasta na prawach powiatu maja wlasne dane. */
public int importGus() {
int imported = 0;
try {
List<GusBdlClient.Variable> variables = gusClient.variables();
int toYear = PriceSnapshotService.today().getYear();
for (String city : citiesToTrack()) {
try {
String unitId = gusClient.cityUnitId(city);
if (unitId != null) {
List<GusBdlClient.QuarterValue> values = gusClient.quarterlyPrices(unitId, variables, GUS_FROM_YEAR, toYear);
if (!values.isEmpty()) {
Instant now = Instant.now();
transaction.executeWithoutResult(status -> {
repository.deleteBySourceAndCityKey(Source.GUS, PriceSnapshotService.key(city));
repository.saveAll(values.stream()
.map(value -> new ExternalQuarterPrice(Source.GUS, value.year(), value.quarter(),
value.market(), city, value.pricePerM2(), now))
.toList());
});
imported += values.size();
}
}
// Publiczne API bez klucza ma limity zapytan - nie spieszymy sie.
Thread.sleep(700);
} catch (IOException | RuntimeException ex) {
log.warn("GUS: nie udalo sie pobrac danych dla {} ({})", city, ex.getMessage());
}
}
log.info("GUS: zaimportowano {} cen kwartalnych", imported);
} catch (IOException | RuntimeException ex) {
log.warn("GUS: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
return imported;
}
private List<String> citiesToTrack() {
Map<String, String> cities = new LinkedHashMap<>();
repository.findAll().stream()
.filter(price -> price.getSource() == Source.NBP)
.forEach(price -> cities.putIfAbsent(price.getCityKey(), price.getCity()));
listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(PropertyListing::getCity)
.filter(city -> city != null && !city.isBlank())
.forEach(city -> cities.putIfAbsent(PriceSnapshotService.key(city), city.trim()));
return List.copyOf(cities.values());
}
private InputStream download(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).GET().build();
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("serwer odpowiedzial " + response.statusCode());
}
if (response.body().length > MAX_DOWNLOAD_BYTES) {
throw new IOException("plik jest za duzy");
}
return new java.io.ByteArrayInputStream(response.body());
}
}
@@ -0,0 +1,97 @@
package pl.polskalokalnie.pricehistory;
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 jakarta.persistence.UniqueConstraint;
import java.time.Instant;
/**
* Kwartalna srednia cena transakcyjna za m2 mieszkan z zewnetrznego zrodla:
* NBP (baza BaRN, 16 miast wojewodzkich i Gdynia) albo GUS (Bank Danych Lokalnych, miasta na prawach powiatu).
* Rynek: "Pierwotny", "Wtórny" albo "" (oba razem - tylko GUS).
*/
@Entity
@Table(name = "external_quarter_prices",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "year", "quarter", "market", "cityKey"}))
public class ExternalQuarterPrice {
public enum Source { NBP, GUS }
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 8)
private Source source;
@Column(nullable = false)
private int year;
@Column(nullable = false)
private int quarter;
@Column(nullable = false, length = 32)
private String market;
@Column(nullable = false, length = 120)
private String cityKey;
@Column(nullable = false, length = 120)
private String city;
@Column(nullable = false)
private long pricePerM2;
@Column(nullable = false)
private Instant importedAt;
protected ExternalQuarterPrice() {
}
public ExternalQuarterPrice(Source source, int year, int quarter, String market, String city, long pricePerM2,
Instant importedAt) {
this.source = source;
this.year = year;
this.quarter = quarter;
this.market = market;
this.city = city;
this.cityKey = PriceSnapshotService.key(city);
this.pricePerM2 = pricePerM2;
this.importedAt = importedAt;
}
public Source getSource() {
return source;
}
public int getYear() {
return year;
}
public int getQuarter() {
return quarter;
}
public String getMarket() {
return market;
}
public String getCity() {
return city;
}
public String getCityKey() {
return cityKey;
}
public long getPricePerM2() {
return pricePerM2;
}
}
@@ -0,0 +1,26 @@
package pl.polskalokalnie.pricehistory;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
public interface ExternalQuarterPriceRepository extends JpaRepository<ExternalQuarterPrice, Long> {
List<ExternalQuarterPrice> findByCityKeyAndMarketOrderByYearAscQuarterAsc(String cityKey, String market);
@Query("select max(p.importedAt) from ExternalQuarterPrice p where p.source = :source")
Optional<Instant> findLastImport(@Param("source") Source source);
@Modifying
@Query("delete from ExternalQuarterPrice p where p.source = :source")
void deleteBySource(@Param("source") Source source);
@Modifying
@Query("delete from ExternalQuarterPrice p where p.source = :source and p.cityKey = :cityKey")
void deleteBySourceAndCityKey(@Param("source") Source source, @Param("cityKey") String cityKey);
}
@@ -0,0 +1,134 @@
package pl.polskalokalnie.pricehistory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
/**
* Publiczne API GUS - Bank Danych Lokalnych (bdl.stat.gov.pl, bez klucza).
* Temat P3794: srednia cena za 1 m2 lokali mieszkalnych sprzedanych w ramach transakcji rynkowych
* (dane kwartalne), poziom powiatu - miasta na prawach powiatu maja wlasne dane.
*/
@Component
public class GusBdlClient {
static final String SUBJECT = "P3794";
private static final String API = "https://bdl.stat.gov.pl/api/v1";
private static final Pattern QUARTER = Pattern.compile("^(\\d)\\s*kwarta");
/** Zmienna BDL: kwartal i rynek ("" = oba rynki razem). */
public record Variable(long id, int quarter, String market) {
}
public record QuarterValue(int year, int quarter, String market, long pricePerM2) {
}
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
private final ObjectMapper mapper = new ObjectMapper();
/** Id jednostki "Powiat m. <miasto>" / "Powiat m.st. <miasto>" albo null, gdy miasto nie jest na prawach powiatu. */
public String cityUnitId(String city) throws IOException, InterruptedException {
JsonNode results = get("/units/search?level=5&format=json&page-size=20&name="
+ URLEncoder.encode(city, StandardCharsets.UTF_8)).path("results");
return matchCityUnit(results, city);
}
public List<Variable> variables() throws IOException, InterruptedException {
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
}
public List<QuarterValue> quarterlyPrices(String unitId, List<Variable> variables, int fromYear, int toYear)
throws IOException, InterruptedException {
StringBuilder query = new StringBuilder("/data/by-unit/").append(unitId).append("?format=json");
variables.forEach(variable -> query.append("&var-id=").append(variable.id()));
for (int year = fromYear; year <= toYear; year++) {
query.append("&year=").append(year);
}
return parseUnitData(get(query.toString()), variables);
}
static String matchCityUnit(JsonNode results, String city) {
String wanted = normalize(city);
for (JsonNode unit : results) {
String name = normalize(unit.path("name").asText());
// "Powiat m. Kraków", "Powiat m. st. Warszawa" - bez powiatow ziemskich wokol miasta.
if (name.equals("powiat m. " + wanted) || name.equals("powiat m. st. " + wanted)
|| name.equals("powiat m.st. " + wanted)) {
return unit.path("id").asText();
}
}
return null;
}
// Tylko "ogolem" wg powierzchni lokalu - dla kazdego kwartalu i rynku jedna wartosc.
static List<Variable> parseVariables(JsonNode results) {
List<Variable> variables = new ArrayList<>();
for (JsonNode variable : results) {
Matcher quarter = QUARTER.matcher(variable.path("n1").asText());
String market = switch (normalize(variable.path("n2").asText())) {
case "ogółem" -> "";
case "rynek pierwotny" -> "Pierwotny";
case "rynek wtórny" -> "Wtórny";
default -> null;
};
if (quarter.find() && market != null && "ogółem".equals(normalize(variable.path("n3").asText()))) {
variables.add(new Variable(variable.path("id").asLong(), Integer.parseInt(quarter.group(1)), market));
}
}
return variables;
}
static List<QuarterValue> parseUnitData(JsonNode response, List<Variable> variables) {
Map<Long, Variable> byId = new LinkedHashMap<>();
variables.forEach(variable -> byId.put(variable.id(), variable));
List<QuarterValue> values = new ArrayList<>();
for (JsonNode result : response.path("results")) {
Variable variable = byId.get(result.path("id").asLong());
if (variable == null) {
continue;
}
for (JsonNode value : result.path("values")) {
double price = value.path("val").asDouble(0);
int year = value.path("year").asInt(0);
if (price > 0 && year > 0) {
values.add(new QuarterValue(year, variable.quarter(), variable.market(), Math.round(price)));
}
}
}
return values;
}
private JsonNode get(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new IOException("GUS BDL odpowiedzial " + response.statusCode() + " dla " + path);
}
return mapper.readTree(response.body());
}
private static String normalize(String value) {
return Normalizer.normalize(value == null ? "" : value.trim(), Normalizer.Form.NFC)
.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}
@@ -0,0 +1,256 @@
package pl.polskalokalnie.pricehistory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
/**
* Odczyt cen transakcyjnych z pliku NBP ceny_mieszkan.xlsx bez zewnetrznych bibliotek (XLSX to ZIP z XML).
* Z arkuszy "Rynek pierwotny" i "Rynek wtórny" bierze blok "Ceny transakcyjne": kolumne kwartalu
* ("II 2026") i kolumny miast; pomija srednie zbiorcze ("7 miast") oraz indeksy cen.
*/
public final class NbpXlsxParser {
public record Row(int year, int quarter, String market, String city, long pricePerM2) {
}
private static final long MAX_ENTRY_BYTES = 20L * 1024 * 1024;
private static final Pattern QUARTER = Pattern.compile("^(IV|III|II|I)\\s+(\\d{4})$");
private static final Pattern CELL_REF = Pattern.compile("^([A-Z]+)\\d+$");
private static final Map<String, String> SHEET_MARKETS = Map.of(
"Rynek pierwotny", "Pierwotny",
"Rynek wtórny", "Wtórny");
private NbpXlsxParser() {
}
public static List<Row> parse(InputStream xlsx) throws IOException {
Map<String, byte[]> entries = unzip(xlsx);
try {
List<String> sharedStrings = sharedStrings(entries.get("xl/sharedStrings.xml"));
Map<String, String> sheetFiles = sheetFiles(entries);
List<Row> rows = new ArrayList<>();
for (Map.Entry<String, String> sheet : SHEET_MARKETS.entrySet()) {
String file = sheetFiles.get(sheet.getKey());
if (file != null && entries.containsKey(file)) {
rows.addAll(transactionPrices(read(entries.get(file)), sharedStrings, sheet.getValue()));
}
}
return rows;
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
throw new IOException("Nie udało się odczytać pliku NBP: " + ex.getMessage(), ex);
}
}
private static List<Row> transactionPrices(Document sheet, List<String> shared, String market) {
List<Map<String, String>> table = new ArrayList<>();
NodeList rowNodes = sheet.getElementsByTagName("row");
for (int i = 0; i < rowNodes.getLength(); i++) {
Map<String, String> cells = new LinkedHashMap<>();
NodeList cellNodes = ((Element) rowNodes.item(i)).getElementsByTagName("c");
for (int j = 0; j < cellNodes.getLength(); j++) {
Element cell = (Element) cellNodes.item(j);
Matcher ref = CELL_REF.matcher(cell.getAttribute("r"));
String value = cellValue(cell, shared);
if (ref.matches() && value != null && !value.isBlank()) {
cells.put(ref.group(1), value.trim());
}
}
table.add(cells);
}
String transactionColumn = findColumn(table, "Ceny transakcyjne");
Map<String, String> header = table.stream()
.filter(row -> row.values().stream().filter("Kwartał"::equals).count() >= 2)
.findFirst()
.orElse(null);
if (transactionColumn == null || header == null) {
return List.of();
}
// Kolumna kwartalu bloku transakcyjnego: ostatnia "Kwartał" nie dalej niz naglowek bloku.
String quarterColumn = null;
for (Map.Entry<String, String> cell : header.entrySet()) {
if ("Kwartał".equals(cell.getValue()) && columnIndex(cell.getKey()) <= columnIndex(transactionColumn)) {
quarterColumn = cell.getKey();
}
}
if (quarterColumn == null) {
return List.of();
}
Map<String, String> cityColumns = new LinkedHashMap<>();
for (int index = columnIndex(quarterColumn) + 1; ; index++) {
String name = header.get(columnName(index));
if (name == null || name.toLowerCase().contains("miast")) {
break;
}
cityColumns.put(columnName(index), name.replace("*", "").trim());
}
List<Row> result = new ArrayList<>();
for (Map<String, String> row : table) {
Matcher quarter = QUARTER.matcher(row.getOrDefault(quarterColumn, ""));
if (!quarter.matches()) {
continue;
}
int quarterNumber = switch (quarter.group(1)) {
case "I" -> 1;
case "II" -> 2;
case "III" -> 3;
default -> 4;
};
int year = Integer.parseInt(quarter.group(2));
cityColumns.forEach((column, city) -> {
Double price = number(row.get(column));
if (price != null && price > 0) {
result.add(new Row(year, quarterNumber, market, city, Math.round(price)));
}
});
}
return result;
}
private static String findColumn(List<Map<String, String>> table, String text) {
for (Map<String, String> row : table) {
for (Map.Entry<String, String> cell : row.entrySet()) {
if (text.equals(cell.getValue())) {
return cell.getKey();
}
}
}
return null;
}
private static String cellValue(Element cell, List<String> shared) {
String type = cell.getAttribute("t");
if ("inlineStr".equals(type)) {
return textOf(cell);
}
NodeList values = cell.getElementsByTagName("v");
if (values.getLength() == 0) {
return null;
}
String raw = values.item(0).getTextContent();
if ("s".equals(type)) {
int index = Integer.parseInt(raw.trim());
return index < shared.size() ? shared.get(index) : null;
}
return raw;
}
private static List<String> sharedStrings(byte[] xml) throws Exception {
List<String> strings = new ArrayList<>();
if (xml == null) {
return strings;
}
NodeList items = read(xml).getElementsByTagName("si");
for (int i = 0; i < items.getLength(); i++) {
strings.add(textOf((Element) items.item(i)));
}
return strings;
}
private static String textOf(Element element) {
StringBuilder text = new StringBuilder();
NodeList parts = element.getElementsByTagName("t");
for (int i = 0; i < parts.getLength(); i++) {
text.append(parts.item(i).getTextContent());
}
return text.toString();
}
// Nazwa arkusza -> sciezka pliku w ZIP (workbook.xml + relacje).
private static Map<String, String> sheetFiles(Map<String, byte[]> entries) throws Exception {
Map<String, String> targets = new HashMap<>();
NodeList relations = read(entries.get("xl/_rels/workbook.xml.rels")).getElementsByTagName("Relationship");
for (int i = 0; i < relations.getLength(); i++) {
Element relation = (Element) relations.item(i);
String target = relation.getAttribute("Target");
targets.put(relation.getAttribute("Id"), target.startsWith("/") ? target.substring(1) : "xl/" + target);
}
Map<String, String> files = new HashMap<>();
NodeList sheets = read(entries.get("xl/workbook.xml")).getElementsByTagName("sheet");
for (int i = 0; i < sheets.getLength(); i++) {
Element sheet = (Element) sheets.item(i);
files.put(sheet.getAttribute("name").trim(), targets.get(sheet.getAttribute("r:id")));
}
return files;
}
private static Map<String, byte[]> unzip(InputStream input) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
byte[] data = zip.readNBytes((int) MAX_ENTRY_BYTES + 1);
if (data.length > MAX_ENTRY_BYTES) {
throw new IOException("Plik NBP ma nieoczekiwanie duzy element: " + entry.getName());
}
entries.put(entry.getName(), data);
}
}
if (!entries.containsKey("xl/workbook.xml")) {
throw new IOException("To nie jest plik XLSX");
}
return entries;
}
private static Document read(byte[] xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// Plik z zewnatrz - bez encji zewnetrznych i DTD (ochrona przed XXE).
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(xml));
}
private static Double number(String value) {
if (value == null) {
return null;
}
try {
return Double.parseDouble(value.replace(',', '.'));
} catch (NumberFormatException ex) {
return null;
}
}
private static int columnIndex(String column) {
int index = 0;
for (char letter : column.toCharArray()) {
index = index * 26 + (letter - 'A' + 1);
}
return index;
}
private static String columnName(int index) {
StringBuilder name = new StringBuilder();
while (index > 0) {
int remainder = (index - 1) % 26;
name.insert(0, (char) ('A' + remainder));
index = (index - 1) / 26;
}
return name.toString();
}
}
@@ -0,0 +1,81 @@
package pl.polskalokalnie.pricehistory;
import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
/** Publiczna historia srednich cen ofertowych za m2 dla miasta lub dzielnicy. */
@RestController
@RequestMapping("/api/price-history")
public class PriceHistoryController {
private final PriceSnapshotRepository snapshotRepository;
private final PriceSnapshotService snapshotService;
private final ExternalQuarterPriceRepository externalRepository;
public PriceHistoryController(PriceSnapshotRepository snapshotRepository, PriceSnapshotService snapshotService,
ExternalQuarterPriceRepository externalRepository) {
this.snapshotRepository = snapshotRepository;
this.snapshotService = snapshotService;
this.externalRepository = externalRepository;
}
public record PricePoint(LocalDate date, long avgPricePerM2, int listingsCount) {
}
public record QuarterPoint(int year, int quarter, long pricePerM2) {
}
/**
* points - dzienne zapisy od "from"; current - srednia z ogloszen w tej chwili (null, gdy brak);
* trackedSince - od kiedy serwis w ogole zbiera historie (null, gdy jeszcze nic nie zapisano);
* nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta (puste, gdy zrodlo go nie obejmuje);
* nbpMarket - rynek, ktorego dotyczy nbp (NBP nie podaje obu rynkow razem - wtedy wtorny).
*/
public record PriceHistoryResponse(List<PricePoint> points, PricePoint current, LocalDate trackedSince,
List<QuarterPoint> nbp, List<QuarterPoint> gus, String nbpMarket) {
}
@GetMapping
public PriceHistoryResponse history(
@RequestParam String city,
@RequestParam(required = false, defaultValue = "") String district,
@RequestParam(required = false, defaultValue = "SALE") OfferType offerType,
@RequestParam(required = false, defaultValue = "APARTMENT") PropertyType propertyType,
@RequestParam(required = false, defaultValue = "") String market,
@RequestParam(required = false) LocalDate from
) {
LocalDate since = from != null ? from : PriceSnapshotService.today().minusYears(1);
String wantedMarket = market.trim();
List<PricePoint> points = snapshotRepository
.findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
PriceSnapshotService.key(city), PriceSnapshotService.key(district), offerType, propertyType,
wantedMarket, since)
.stream()
.map(snapshot -> new PricePoint(snapshot.getSnapshotDate(), snapshot.getAvgPricePerM2(), snapshot.getListingsCount()))
.toList();
PriceSnapshotService.Average now = snapshotService.currentAverage(offerType, propertyType, wantedMarket, city, district);
PricePoint current = now == null ? null
: new PricePoint(PriceSnapshotService.today(), now.avgPricePerM2(), now.listingsCount());
// NBP i GUS podaja ceny transakcyjne mieszkan - dla domow i najmu nie ma porownania.
boolean comparable = offerType == OfferType.SALE && propertyType == PropertyType.APARTMENT;
String nbpMarket = wantedMarket.isEmpty() ? "Wtórny" : wantedMarket;
String cityKey = PriceSnapshotService.key(city);
List<QuarterPoint> nbp = comparable ? quarters(cityKey, nbpMarket, ExternalQuarterPrice.Source.NBP) : List.of();
List<QuarterPoint> gus = comparable ? quarters(cityKey, wantedMarket, ExternalQuarterPrice.Source.GUS) : List.of();
return new PriceHistoryResponse(points, current, snapshotRepository.findFirstSnapshotDate().orElse(null),
nbp, gus, nbpMarket);
}
private List<QuarterPoint> quarters(String cityKey, String market, ExternalQuarterPrice.Source source) {
return externalRepository.findByCityKeyAndMarketOrderByYearAscQuarterAsc(cityKey, market).stream()
.filter(price -> price.getSource() == source)
.map(price -> new QuarterPoint(price.getYear(), price.getQuarter(), price.getPricePerM2()))
.toList();
}
}
@@ -0,0 +1,94 @@
package pl.polskalokalnie.pricehistory;
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.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
/**
* Dzienny zapis sredniej ceny ofertowej za m2 z opublikowanych ogloszen - zrodlo historii cen.
* Jeden wiersz na (dzien, typ oferty, typ nieruchomosci, rynek, miasto, dzielnica).
* Pusta dzielnica = cale miasto, pusty rynek = wtorny i pierwotny razem.
* Klucze (cityKey, districtKey) sa znormalizowane, zeby "Kraków" i " kraków" trafialy w to samo.
*/
@Entity
@Table(name = "price_snapshots",
uniqueConstraints = @UniqueConstraint(columnNames = {
"snapshotDate", "offerType", "propertyType", "market", "cityKey", "districtKey"}),
indexes = @Index(columnList = "cityKey, districtKey, offerType, propertyType, market, snapshotDate"))
public class PriceSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private LocalDate snapshotDate;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private OfferType offerType;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private PropertyType propertyType;
@Column(nullable = false, length = 32)
private String market;
@Column(nullable = false, length = 120)
private String cityKey;
@Column(nullable = false, length = 120)
private String districtKey;
@Column(nullable = false, length = 120)
private String city;
@Column(nullable = false, length = 120)
private String district;
@Column(nullable = false)
private int listingsCount;
@Column(nullable = false)
private long avgPricePerM2;
protected PriceSnapshot() {
}
public PriceSnapshot(LocalDate snapshotDate, OfferType offerType, PropertyType propertyType, String market,
String city, String district, int listingsCount, long avgPricePerM2) {
this.snapshotDate = snapshotDate;
this.offerType = offerType;
this.propertyType = propertyType;
this.market = market;
this.city = city;
this.district = district;
this.cityKey = PriceSnapshotService.key(city);
this.districtKey = PriceSnapshotService.key(district);
this.listingsCount = listingsCount;
this.avgPricePerM2 = avgPricePerM2;
}
public LocalDate getSnapshotDate() {
return snapshotDate;
}
public int getListingsCount() {
return listingsCount;
}
public long getAvgPricePerM2() {
return avgPricePerM2;
}
}
@@ -0,0 +1,26 @@
package pl.polskalokalnie.pricehistory;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
public interface PriceSnapshotRepository extends JpaRepository<PriceSnapshot, Long> {
boolean existsBySnapshotDate(LocalDate snapshotDate);
@Modifying
@Query("delete from PriceSnapshot s where s.snapshotDate = :date")
void deleteBySnapshotDate(@Param("date") LocalDate date);
List<PriceSnapshot> findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
String cityKey, String districtKey, OfferType offerType, PropertyType propertyType, String market, LocalDate from);
@Query("select min(s.snapshotDate) from PriceSnapshot s")
Optional<LocalDate> findFirstSnapshotDate();
}
@@ -0,0 +1,160 @@
package pl.polskalokalnie.pricehistory;
import java.text.Normalizer;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
/**
* Historia cen: codzienny zapis srednich cen ofertowych za m2 z opublikowanych ogloszen.
* Danych sprzed pierwszego zapisu nie odtwarzamy - historia rosnie z kazdym dniem.
*/
@Service
public class PriceSnapshotService {
public static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
/** Wartosc rynku oznaczajaca "wtorny i pierwotny razem". */
public static final String ALL_MARKETS = "";
private static final Logger log = LoggerFactory.getLogger(PriceSnapshotService.class);
private final ListingRepository listingRepository;
private final PriceSnapshotRepository snapshotRepository;
public PriceSnapshotService(ListingRepository listingRepository, PriceSnapshotRepository snapshotRepository) {
this.listingRepository = listingRepository;
this.snapshotRepository = snapshotRepository;
}
/** Srednia na dany moment - ta sama grupa, co w zapisie dziennym. */
public record Average(long avgPricePerM2, int listingsCount) {
}
private record GroupKey(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
}
// Co noc zapisujemy stan z konca dnia; przy starcie uzupelniamy dzisiejszy zapis, jesli go brakuje.
// Transakcja na tych metodach, bo wywolanie recordToday() z wnetrza klasy omija proxy Springa.
@Scheduled(cron = "0 30 2 * * *", zone = "Europe/Warsaw")
@Transactional
public void recordDaily() {
recordToday();
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void recordOnStartupIfMissing() {
if (!snapshotRepository.existsBySnapshotDate(today())) {
recordToday();
}
}
/** Zapis dzisiejszych srednich; ponowne wywolanie w tym samym dniu nadpisuje dzisiejsze wiersze. */
@Transactional
public int recordToday() {
LocalDate date = today();
snapshotRepository.deleteBySnapshotDate(date);
Map<GroupKey, List<Double>> groups = group(pricedListings());
List<PriceSnapshot> rows = new ArrayList<>();
groups.forEach((key, prices) -> rows.add(new PriceSnapshot(date, key.offerType(), key.propertyType(), key.market(),
key.city(), key.district(), prices.size(), Math.round(average(prices)))));
snapshotRepository.saveAll(rows);
log.info("Historia cen: zapisano {} srednich za {}", rows.size(), date);
return rows.size();
}
/** Aktualna srednia liczona wprost z ogloszen - "dzisiaj" na wykresie jest zawsze swieze. */
public Average currentAverage(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
String wantedCity = key(city);
String wantedDistrict = key(district);
List<Double> prices = pricedListings().stream()
.filter(listing -> listing.getOfferType() == offerType && listing.getPropertyType() == propertyType)
.filter(listing -> key(listing.getCity()).equals(wantedCity))
.filter(listing -> wantedDistrict.isEmpty() || key(listing.getDistrict()).equals(wantedDistrict))
.filter(listing -> market.isEmpty() || market.equalsIgnoreCase(trim(listing.getMarket())))
.map(PriceSnapshotService::pricePerM2)
.toList();
return prices.isEmpty() ? null : new Average(Math.round(average(prices)), prices.size());
}
private List<PropertyListing> pricedListings() {
return listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> listing.getOfferType() != null && listing.getPropertyType() != null)
.filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0)
.filter(listing -> listing.getArea() != null && listing.getArea() > 0)
.filter(listing -> !trim(listing.getCity()).isEmpty())
.toList();
}
// Kazde ogloszenie liczy sie do miasta i (jesli ma) do dzielnicy - dla wszystkich rynkow i dla swojego rynku.
private static Map<GroupKey, List<Double>> group(List<PropertyListing> listings) {
Map<GroupKey, List<Double>> groups = new LinkedHashMap<>();
for (PropertyListing listing : listings) {
double price = pricePerM2(listing);
String city = trim(listing.getCity());
String district = trim(listing.getDistrict());
String market = trim(listing.getMarket());
List<String> markets = market.isEmpty() ? List.of(ALL_MARKETS) : List.of(ALL_MARKETS, market);
List<String> districts = district.isEmpty() ? List.of("") : List.of("", district);
for (String groupMarket : markets) {
for (String groupDistrict : districts) {
GroupKey key = new GroupKey(listing.getOfferType(), listing.getPropertyType(), groupMarket,
city, groupDistrict);
groups.computeIfAbsent(canonical(groups, key), ignored -> new ArrayList<>()).add(price);
}
}
}
return groups;
}
// Ta sama grupa niezaleznie od wielkosci liter i spacji - pierwsza napotkana pisownia zostaje nazwa.
private static GroupKey canonical(Map<GroupKey, List<Double>> groups, GroupKey candidate) {
for (GroupKey existing : groups.keySet()) {
if (existing.offerType() == candidate.offerType() && existing.propertyType() == candidate.propertyType()
&& existing.market().equalsIgnoreCase(candidate.market())
&& key(existing.city()).equals(key(candidate.city()))
&& key(existing.district()).equals(key(candidate.district()))) {
return existing;
}
}
return candidate;
}
public static LocalDate today() {
return LocalDate.now(ZONE);
}
/** Klucz porownania nazw: male litery, bez nadmiarowych spacji (polskie znaki zostaja). */
public static String key(String value) {
return Normalizer.normalize(trim(value), Normalizer.Form.NFC).toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static double pricePerM2(PropertyListing listing) {
return listing.getPrice().doubleValue() / listing.getArea();
}
private static double average(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
}
}
@@ -0,0 +1,49 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.jupiter.api.Test;
// Probki w formacie odpowiedzi bdl.stat.gov.pl/api/v1 (sprawdzonym 2026-09-22).
class GusBdlClientTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void matchesOnlyCityWithPowiatRights() throws Exception {
var units = mapper.readTree("""
[{"id":"071412865000","name":"Powiat m. st. Warszawa"},
{"id":"011212106000","name":"Powiat krakowski"},
{"id":"011212161000","name":"Powiat m. Kraków"}]""");
assertThat(GusBdlClient.matchCityUnit(units, "Warszawa")).isEqualTo("071412865000");
assertThat(GusBdlClient.matchCityUnit(units, "kraków")).isEqualTo("011212161000");
assertThat(GusBdlClient.matchCityUnit(units, "Zabierzów")).isNull();
}
@Test
void readsQuarterlyTotalsPerMarket() throws Exception {
var variables = GusBdlClient.parseVariables(mapper.readTree("""
[{"id":634007,"n1":"1 kwartał","n2":"ogółem","n3":"ogółem"},
{"id":634008,"n1":"1 kwartał","n2":"ogółem","n3":"do 40 m2"},
{"id":634012,"n1":"1 kwartał","n2":"rynek pierwotny","n3":"ogółem"},
{"id":634032,"n1":"2 kwartał","n2":"rynek wtórny","n3":"ogółem"}]"""));
assertThat(variables).containsExactly(
new GusBdlClient.Variable(634007, 1, ""),
new GusBdlClient.Variable(634012, 1, "Pierwotny"),
new GusBdlClient.Variable(634032, 2, "Wtórny"));
List<GusBdlClient.QuarterValue> values = GusBdlClient.parseUnitData(mapper.readTree("""
{"results":[
{"id":634007,"values":[{"year":"2024","val":13210.4},{"year":"2025","val":0}]},
{"id":634032,"values":[{"year":"2024","val":14135}]},
{"id":999999,"values":[{"year":"2024","val":1}]}]}"""), variables);
assertThat(values).containsExactly(
new GusBdlClient.QuarterValue(2024, 1, "", 13210),
new GusBdlClient.QuarterValue(2024, 2, "Wtórny", 14135));
}
}
@@ -0,0 +1,41 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.Test;
class NbpXlsxParserTest {
// Plik NBP (ceny_mieszkan.xlsx) pobrany 2026-09-22 - prawdziwy uklad arkuszy.
private List<NbpXlsxParser.Row> parseFixture() throws Exception {
try (InputStream xlsx = getClass().getResourceAsStream("/nbp/ceny_mieszkan.xlsx")) {
return NbpXlsxParser.parse(xlsx);
}
}
@Test
void readsTransactionPricesPerCityAndMarket() throws Exception {
List<NbpXlsxParser.Row> rows = parseFixture();
// Wartosci z arkusza "Rynek pierwotny", blok "Ceny transakcyjne", III kw. 2006.
assertThat(rows).contains(
new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Kraków", 6816),
new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Białystok", 3008));
// Oba rynki, "Gdynia**" bez gwiazdek, najnowszy kwartal w pliku.
assertThat(rows).anyMatch(row -> row.market().equals("Wtórny") && row.city().equals("Warszawa")
&& row.year() == 2026 && row.quarter() == 2);
assertThat(rows).anyMatch(row -> row.city().equals("Gdynia"));
}
@Test
void skipsAggregatesAndOfferPrices() throws Exception {
List<NbpXlsxParser.Row> rows = parseFixture();
assertThat(rows).noneMatch(row -> row.city().contains("miast"));
// Cena ofertowa Krakowa na rynku pierwotnym w III kw. 2006 to 6021 - nie moze trafic do transakcyjnych.
assertThat(rows).doesNotContain(new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Kraków", 6021));
assertThat(rows.stream().map(NbpXlsxParser.Row::city).distinct()).hasSize(17);
}
}
@@ -0,0 +1,94 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
class PriceSnapshotServiceTest {
private final ListingRepository listings = mock(ListingRepository.class);
private final PriceSnapshotRepository snapshots = mock(PriceSnapshotRepository.class);
private final PriceSnapshotService service = new PriceSnapshotService(listings, snapshots);
@Test
@SuppressWarnings("unchecked")
void recordsCityDistrictAndMarketAverages() {
when(listings.findAll()).thenReturn(List.of(
listing("Kraków", "Podgórze", "Wtórny", 1_000_000, 100, ListingStatus.APPROVED), // 10 000 zl/m2
listing(" kraków ", "podgórze", "Pierwotny", 600_000, 50, ListingStatus.APPROVED), // 12 000 zl/m2
listing("Kraków", null, null, 800_000, 100, ListingStatus.APPROVED), // 8 000 zl/m2
listing("Kraków", "Podgórze", "Wtórny", 9_000_000, 10, ListingStatus.PENDING))); // nieopublikowane
service.recordToday();
ArgumentCaptor<List<PriceSnapshot>> saved = ArgumentCaptor.forClass(List.class);
verify(snapshots).saveAll(saved.capture());
List<PriceSnapshot> rows = saved.getValue();
// Cale miasto, wszystkie rynki: (10 000 + 12 000 + 8 000) / 3.
assertThat(find(rows, "", "")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount)
.containsExactly(10_000L, 3);
// Dzielnica bez wzgledu na wielkosc liter: (10 000 + 12 000) / 2.
assertThat(find(rows, "podgórze", "")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount)
.containsExactly(11_000L, 2);
// Tylko rynek wtorny w miescie.
assertThat(find(rows, "", "Wtórny")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount)
.containsExactly(10_000L, 1);
}
@Test
void currentAverageUsesOnlyMatchingPublishedListings() {
when(listings.findAll()).thenReturn(List.of(
listing("Kraków", "Podgórze", "Wtórny", 1_000_000, 100, ListingStatus.APPROVED),
listing("Kraków", "Podgórze", "Wtórny", 9_000_000, 10, ListingStatus.PAUSED),
listing("Warszawa", "Mokotów", "Wtórny", 2_000_000, 100, ListingStatus.APPROVED)));
PriceSnapshotService.Average average = service.currentAverage(
OfferType.SALE, PropertyType.APARTMENT, "", "KRAKÓW", "");
assertThat(average).isEqualTo(new PriceSnapshotService.Average(10_000L, 1));
assertThat(service.currentAverage(OfferType.SALE, PropertyType.APARTMENT, "", "Gdańsk", "")).isNull();
}
private static PriceSnapshot find(List<PriceSnapshot> rows, String districtKey, String market) {
return rows.stream()
.filter(row -> readField(row, "districtKey").equals(districtKey) && readField(row, "market").equals(market))
.findFirst()
.orElseThrow(() -> new AssertionError("brak wiersza " + districtKey + "/" + market));
}
private static String readField(PriceSnapshot row, String name) {
try {
var field = PriceSnapshot.class.getDeclaredField(name);
field.setAccessible(true);
return (String) field.get(row);
} catch (ReflectiveOperationException ex) {
throw new IllegalStateException(ex);
}
}
private static PropertyListing listing(String city, String district, String market, int price, double area,
ListingStatus status) {
PropertyListing listing = new PropertyListing();
listing.setOfferType(OfferType.SALE);
listing.setPropertyType(PropertyType.APARTMENT);
listing.setCity(city);
listing.setDistrict(district);
listing.setMarket(market);
listing.setPrice(BigDecimal.valueOf(price));
listing.setArea(area);
listing.setStatus(status);
return listing;
}
}
Binary file not shown.
+435 -212
View File
@@ -20938,204 +20938,208 @@ function MegaSearch({ filters, onOpenMap, onSearch, mode = 'buy' }: {
type HistoryPropertyType = 'Mieszkanie' | 'Dom' | 'Lokal użytkowy';
type HistoryMarketType = 'Wtórny i pierwotny' | 'Wtórny' | 'Pierwotny';
type HistoryPeriod = '2026';
type HistoryPeriod = '2026' | '5y' | 'all';
type PriceHistoryMarket = {
key: string;
label: string;
city: string;
region: string;
apartmentM2: number;
trend24: number;
volatility: number;
aliases?: string[];
};
const HISTORY_PRICE_MARKETS: PriceHistoryMarket[] = [
{ key: 'warszawa-mokotow', label: 'Warszawa, Mokotów', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 16842, trend24: 0.086, volatility: 0.032, aliases: ['mokotów'] },
{ key: 'warszawa-srodmiescie', label: 'Warszawa, Śródmieście', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 18932, trend24: 0.094, volatility: 0.036, aliases: ['śródmieście warszawa'] },
{ key: 'warszawa-wola', label: 'Warszawa, Wola', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 16123, trend24: 0.083, volatility: 0.031, aliases: ['wola'] },
{ key: 'warszawa-praga-poludnie', label: 'Warszawa, Praga-Południe', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 13254, trend24: 0.071, volatility: 0.029, aliases: ['praga południe', 'praga-poludnie'] },
{ key: 'warszawa-bielany', label: 'Warszawa, Bielany', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 12876, trend24: 0.067, volatility: 0.027, aliases: ['bielany'] },
{ key: 'krakow-zablocie', label: 'Kraków, Zabłocie', city: 'Kraków', region: 'małopolskie', apartmentM2: 15840, trend24: 0.091, volatility: 0.034, aliases: ['zabłocie'] },
{ key: 'krakow-stare-miasto', label: 'Kraków, Stare Miasto', city: 'Kraków', region: 'małopolskie', apartmentM2: 17490, trend24: 0.088, volatility: 0.037 },
{ key: 'krakow-krowodrza', label: 'Kraków, Krowodrza', city: 'Kraków', region: 'małopolskie', apartmentM2: 14320, trend24: 0.079, volatility: 0.031 },
{ key: 'wroclaw-krzyki', label: 'Wrocław, Krzyki', city: 'Wrocław', region: 'dolnośląskie', apartmentM2: 13480, trend24: 0.082, volatility: 0.032, aliases: ['krzyki'] },
{ key: 'wroclaw-stare-miasto', label: 'Wrocław, Stare Miasto', city: 'Wrocław', region: 'dolnośląskie', apartmentM2: 14750, trend24: 0.084, volatility: 0.034 },
{ key: 'gdansk-wrzeszcz', label: 'Gdańsk, Wrzeszcz', city: 'Gdańsk', region: 'pomorskie', apartmentM2: 14320, trend24: 0.087, volatility: 0.036, aliases: ['wrzeszcz'] },
{ key: 'gdansk-srodmiescie', label: 'Gdańsk, Śródmieście', city: 'Gdańsk', region: 'pomorskie', apartmentM2: 16080, trend24: 0.089, volatility: 0.039 },
{ key: 'gdynia-redlowo', label: 'Gdynia, Redłowo', city: 'Gdynia', region: 'pomorskie', apartmentM2: 13960, trend24: 0.075, volatility: 0.034 },
{ key: 'sopot-centrum', label: 'Sopot, Centrum', city: 'Sopot', region: 'pomorskie', apartmentM2: 18850, trend24: 0.079, volatility: 0.043 },
{ key: 'poznan-jezyce', label: 'Poznań, Jeżyce', city: 'Poznań', region: 'wielkopolskie', apartmentM2: 11980, trend24: 0.064, volatility: 0.028, aliases: ['jeżyce'] },
{ key: 'poznan-grunwald', label: 'Poznań, Grunwald', city: 'Poznań', region: 'wielkopolskie', apartmentM2: 11150, trend24: 0.058, volatility: 0.026 },
{ key: 'lodz-srodmiescie', label: 'Łódź, Śródmieście', city: 'Łódź', region: 'łódzkie', apartmentM2: 9350, trend24: 0.054, volatility: 0.028 },
{ key: 'lodz-widzew', label: 'Łódź, Widzew', city: 'Łódź', region: 'łódzkie', apartmentM2: 8420, trend24: 0.047, volatility: 0.026 },
{ key: 'katowice-koszutka', label: 'Katowice, Koszutka', city: 'Katowice', region: 'śląskie', apartmentM2: 10320, trend24: 0.059, volatility: 0.029 },
{ key: 'rzeszow-centrum', label: 'Rzeszów, Centrum', city: 'Rzeszów', region: 'podkarpackie', apartmentM2: 10380, trend24: 0.069, volatility: 0.031 },
{ key: 'lublin-centrum', label: 'Lublin, Centrum', city: 'Lublin', region: 'lubelskie', apartmentM2: 10120, trend24: 0.061, volatility: 0.028 },
{ key: 'bialystok-centrum', label: 'Białystok, Centrum', city: 'Białystok', region: 'podlaskie', apartmentM2: 9580, trend24: 0.053, volatility: 0.025 },
{ key: 'szczecin-centrum', label: 'Szczecin, Centrum', city: 'Szczecin', region: 'zachodniopomorskie', apartmentM2: 9840, trend24: 0.056, volatility: 0.027 },
{ key: 'bydgoszcz-srodmiescie', label: 'Bydgoszcz, Śródmieście', city: 'Bydgoszcz', region: 'kujawsko-pomorskie', apartmentM2: 8230, trend24: 0.044, volatility: 0.025 },
{ key: 'torun-centrum', label: 'Toruń, Centrum', city: 'Toruń', region: 'kujawsko-pomorskie', apartmentM2: 9140, trend24: 0.049, volatility: 0.026 },
{ key: 'olsztyn-centrum', label: 'Olsztyn, Centrum', city: 'Olsztyn', region: 'warmińsko-mazurskie', apartmentM2: 9120, trend24: 0.05, volatility: 0.026 },
{ key: 'opole-centrum', label: 'Opole, Centrum', city: 'Opole', region: 'opolskie', apartmentM2: 8840, trend24: 0.047, volatility: 0.025 },
{ key: 'kielce-centrum', label: 'Kielce, Centrum', city: 'Kielce', region: 'świętokrzyskie', apartmentM2: 8060, trend24: 0.042, volatility: 0.024 },
{ key: 'zielona-gora-centrum', label: 'Zielona Góra, Centrum', city: 'Zielona Góra', region: 'lubuskie', apartmentM2: 8120, trend24: 0.043, volatility: 0.025 },
{ key: 'zakopane-centrum', label: 'Zakopane, Centrum', city: 'Zakopane', region: 'małopolskie', apartmentM2: 17200, trend24: 0.081, volatility: 0.045 },
{ key: 'warszawa-mokotow', label: 'Warszawa, Mokotów', city: 'Warszawa', region: 'mazowieckie', aliases: ['mokotów'] },
{ key: 'warszawa-srodmiescie', label: 'Warszawa, Śródmieście', city: 'Warszawa', region: 'mazowieckie', aliases: ['śródmieście warszawa'] },
{ key: 'warszawa-wola', label: 'Warszawa, Wola', city: 'Warszawa', region: 'mazowieckie', aliases: ['wola'] },
{ key: 'warszawa-praga-poludnie', label: 'Warszawa, Praga-Południe', city: 'Warszawa', region: 'mazowieckie', aliases: ['praga południe', 'praga-poludnie'] },
{ key: 'warszawa-bielany', label: 'Warszawa, Bielany', city: 'Warszawa', region: 'mazowieckie', aliases: ['bielany'] },
{ key: 'krakow-zablocie', label: 'Kraków, Zabłocie', city: 'Kraków', region: 'małopolskie', aliases: ['zabłocie'] },
{ key: 'krakow-stare-miasto', label: 'Kraków, Stare Miasto', city: 'Kraków', region: 'małopolskie' },
{ key: 'krakow-krowodrza', label: 'Kraków, Krowodrza', city: 'Kraków', region: 'małopolskie' },
{ key: 'wroclaw-krzyki', label: 'Wrocław, Krzyki', city: 'Wrocław', region: 'dolnośląskie', aliases: ['krzyki'] },
{ key: 'wroclaw-stare-miasto', label: 'Wrocław, Stare Miasto', city: 'Wrocław', region: 'dolnośląskie' },
{ key: 'gdansk-wrzeszcz', label: 'Gdańsk, Wrzeszcz', city: 'Gdańsk', region: 'pomorskie', aliases: ['wrzeszcz'] },
{ key: 'gdansk-srodmiescie', label: 'Gdańsk, Śródmieście', city: 'Gdańsk', region: 'pomorskie' },
{ key: 'gdynia-redlowo', label: 'Gdynia, Redłowo', city: 'Gdynia', region: 'pomorskie' },
{ key: 'sopot-centrum', label: 'Sopot, Centrum', city: 'Sopot', region: 'pomorskie' },
{ key: 'poznan-jezyce', label: 'Poznań, Jeżyce', city: 'Poznań', region: 'wielkopolskie', aliases: ['jeżyce'] },
{ key: 'poznan-grunwald', label: 'Poznań, Grunwald', city: 'Poznań', region: 'wielkopolskie' },
{ key: 'lodz-srodmiescie', label: 'Łódź, Śródmieście', city: 'Łódź', region: 'łódzkie' },
{ key: 'lodz-widzew', label: 'Łódź, Widzew', city: 'Łódź', region: 'łódzkie' },
{ key: 'katowice-koszutka', label: 'Katowice, Koszutka', city: 'Katowice', region: 'śląskie' },
{ key: 'rzeszow-centrum', label: 'Rzeszów, Centrum', city: 'Rzeszów', region: 'podkarpackie' },
{ key: 'lublin-centrum', label: 'Lublin, Centrum', city: 'Lublin', region: 'lubelskie' },
{ key: 'bialystok-centrum', label: 'Białystok, Centrum', city: 'Białystok', region: 'podlaskie' },
{ key: 'szczecin-centrum', label: 'Szczecin, Centrum', city: 'Szczecin', region: 'zachodniopomorskie' },
{ key: 'bydgoszcz-srodmiescie', label: 'Bydgoszcz, Śródmieście', city: 'Bydgoszcz', region: 'kujawsko-pomorskie' },
{ key: 'torun-centrum', label: 'Toruń, Centrum', city: 'Toruń', region: 'kujawsko-pomorskie' },
{ key: 'olsztyn-centrum', label: 'Olsztyn, Centrum', city: 'Olsztyn', region: 'warmińsko-mazurskie' },
{ key: 'opole-centrum', label: 'Opole, Centrum', city: 'Opole', region: 'opolskie' },
{ key: 'kielce-centrum', label: 'Kielce, Centrum', city: 'Kielce', region: 'świętokrzyskie' },
{ key: 'zielona-gora-centrum', label: 'Zielona Góra, Centrum', city: 'Zielona Góra', region: 'lubuskie' },
{ key: 'zakopane-centrum', label: 'Zakopane, Centrum', city: 'Zakopane', region: 'małopolskie' },
];
const HISTORY_CITY_BASE_PRICES: Record<string, number> = {
Warszawa: 16000, Kraków: 14500, Wrocław: 12600, Gdańsk: 13600, Gdynia: 12700, Sopot: 17800,
Poznań: 10800, Łódź: 8500, Katowice: 9700, Rzeszów: 9800, Lublin: 9300, Białystok: 8900,
Szczecin: 9200, Bydgoszcz: 7600, Toruń: 8500, Kielce: 7400, Olsztyn: 8500, Opole: 8200,
Zielona: 7800, 'Zielona Góra': 7800, Częstochowa: 6900, Radom: 6700, Gliwice: 7800,
Zabrze: 6100, Rybnik: 6200, Tychy: 7600, Bielsko: 8600, 'Bielsko-Biała': 8600,
Płock: 7100, Tarnów: 7200, Koszalin: 7900, Kalisz: 7100, Legnica: 7300, Słupsk: 7400,
Nowy: 7600, 'Nowy Sącz': 7600, Jelenia: 7400, 'Jelenia Góra': 7400, Piaseczno: 11200,
Pruszków: 10400, Legionowo: 9400, Wieliczka: 9300, Zakopane: 16000, Kołobrzeg: 12200,
};
const HISTORY_POPULAR_MARKETS = ['Warszawa, Mokotów', 'Kraków, Zabłocie', 'Wrocław, Krzyki', 'Gdańsk, Wrzeszcz', 'Poznań, Jeżyce'];
const HISTORY_PROPERTY_MULTIPLIERS: Record<HistoryPropertyType, number> = { Mieszkanie: 1, Dom: 0.78, 'Lokal użytkowy': 1.12 };
const HISTORY_MARKET_MULTIPLIERS: Record<HistoryMarketType, number> = { 'Wtórny i pierwotny': 1, Wtórny: 0.97, Pierwotny: 1.08 };
const HISTORY_LOCATION_TYPE_MULTIPLIERS: Record<string, number> = {
dzielnica: 1,
miasto: 0.96,
town: 0.86,
miejscowość: 0.78,
gmina: 0.76,
powiat: 0.74,
wieś: 0.62,
przysiółek: 0.56,
// Historia cen z API (/api/price-history) - dzienne srednie ceny ofertowe z ogloszen serwisu.
type ApiPricePoint = { date: string; avgPricePerM2: number; listingsCount: number };
type ApiQuarterPoint = { year: number; quarter: number; pricePerM2: number };
// nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta; nbpMarket - rynek serii NBP.
type ApiPriceHistory = {
points: ApiPricePoint[];
current: ApiPricePoint | null;
trackedSince: string | null;
nbp: ApiQuarterPoint[];
gus: ApiQuarterPoint[];
nbpMarket: string;
};
// Ogloszenia serwisu to mieszkania i domy - lokali uzytkowych nie ma z czego liczyc.
const HISTORY_PROPERTY_API: Record<HistoryPropertyType, 'APARTMENT' | 'HOUSE' | null> = {
Mieszkanie: 'APARTMENT',
Dom: 'HOUSE',
'Lokal użytkowy': null,
};
// Pusty rynek = wtorny i pierwotny razem (tak zapisuje backend).
const HISTORY_MARKET_API: Record<HistoryMarketType, string> = {
'Wtórny i pierwotny': '',
Wtórny: 'Wtórny',
Pierwotny: 'Pierwotny',
};
// "Warszawa, Mokotów" -> miasto Warszawa, dzielnica Mokotów; sama nazwa miasta -> cale miasto.
function priceHistoryApiPath(market: PriceHistoryMarket, propertyType: HistoryPropertyType,
marketType: HistoryMarketType, from: string): string {
// Dzielnica to druga czesc etykiety, o ile nie jest nazwa regionu (lokalizacje zapamietane
// przez starsza wersje mialy np. "Kraków, małopolskie").
const district = market.label.split(',').map((part) => part.trim()).slice(1)
.find((part) => part && !isHistoryRegionName(part) && normalizeHistoryText(part) !== normalizeHistoryText(market.region)) ?? '';
const params = new URLSearchParams({
city: market.city,
district,
propertyType: HISTORY_PROPERTY_API[propertyType] ?? 'APARTMENT',
market: HISTORY_MARKET_API[marketType],
from,
});
return `/price-history?${params.toString()}`;
}
const HISTORY_YEAR = 2026;
const HISTORY_YEAR_MONTHS = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'];
// "od maja", "od września" - odmiana do opisu zmiany ceny.
const HISTORY_MONTHS_GENITIVE = ['stycznia', 'lutego', 'marca', 'kwietnia', 'maja', 'czerwca', 'lipca', 'sierpnia', 'września', 'października', 'listopada', 'grudnia'];
const HISTORY_PERIOD_OPTIONS: { value: HistoryPeriod; label: string }[] = [
{ value: '5y', label: 'Ostatnie 5 lat' },
{ value: 'all', label: 'Od 2006 (NBP)' },
{ value: '2026', label: '2026 do dziś' },
];
// Pierwszy kwartal w danych NBP (baza BaRN).
const HISTORY_NBP_FIRST_YEAR = 2006;
const HISTORY_NBP_FIRST_QUARTER = 3;
const HISTORY_ROMAN_QUARTERS = ['I', 'II', 'III', 'IV'];
// Przedzial na osi wykresu: miesiac (okres 2026) albo kwartal (dluzsze okresy).
type HistorySlot = { axisLabel: string; label: string; sinceLabel: string; year: number; month: number | null; quarter: number };
function buildHistorySlots(period: HistoryPeriod): HistorySlot[] {
if (period === '2026') {
return historyMonthLabels().map((label, month) => ({
axisLabel: label,
label: `${label} ${HISTORY_YEAR}`,
sinceLabel: HISTORY_MONTHS_GENITIVE[month],
year: HISTORY_YEAR,
month,
quarter: Math.floor(month / 3) + 1,
}));
}
const now = new Date();
const currentYear = now.getFullYear();
const currentQuarter = Math.floor(now.getMonth() / 3) + 1;
const startYear = period === '5y' ? currentYear - 4 : HISTORY_NBP_FIRST_YEAR;
const slots: HistorySlot[] = [];
for (let year = startYear; year <= currentYear; year += 1) {
for (let quarter = 1; quarter <= 4; quarter += 1) {
if (year === currentYear && quarter > currentQuarter) {
break;
}
if (period === 'all' && year === HISTORY_NBP_FIRST_YEAR && quarter < HISTORY_NBP_FIRST_QUARTER) {
continue;
}
const label = `${HISTORY_ROMAN_QUARTERS[quarter - 1]} kw. ${year}`;
// Podpis roku przy pierwszym kwartale; przy 20 latach co drugi rok, zeby sie nie zlewaly.
const showYear = (quarter === 1 || slots.length === 0) && (period === '5y' || year % 2 === 0);
slots.push({ axisLabel: showYear ? String(year) : '', label, sinceLabel: label, year, month: null, quarter });
}
}
return slots;
}
function historySlotContains(slot: HistorySlot, date: string): boolean {
const year = Number(date.slice(0, 4));
const month = Number(date.slice(5, 7)) - 1;
return year === slot.year && (slot.month != null ? month === slot.month : Math.floor(month / 3) + 1 === slot.quarter);
}
// Wartosc kwartalna na osi: w widoku kwartalnym wprost, w miesiecznym w srodkowym miesiacu kwartalu.
function historyQuarterValue(points: ApiQuarterPoint[], slot: HistorySlot): number | null {
if (slot.month != null && slot.month % 3 !== 1) {
return null;
}
return points.find((point) => point.year === slot.year && point.quarter === slot.quarter)?.pricePerM2 ?? null;
}
// Poczatek okresu dla zapytania o historie.
function historyFromDate(slots: HistorySlot[]): string {
const first = slots[0];
if (!first) {
return `${HISTORY_YEAR}-01-01`;
}
const month = first.month != null ? first.month + 1 : (first.quarter - 1) * 3 + 1;
return `${first.year}-${String(month).padStart(2, '0')}-01`;
}
function normalizeHistoryText(value: string) {
return value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ł/g, 'l').replace(/[^a-z0-9]+/g, ' ').trim();
}
function historyLocationTypeFromText(value: string, city?: string | null) {
const normalized = normalizeHistoryText(value);
if (normalized.includes('dzielnica') || normalized.includes('osiedle')) {
return 'dzielnica';
}
if (normalized.includes('wies') || normalized.includes('przysiolek') || normalized.includes('hamlet') || normalized.includes('osada')) {
return normalized.includes('przysiolek') || normalized.includes('hamlet') ? 'przysiółek' : 'wieś';
}
if (normalized.includes('powiat')) {
return 'powiat';
}
if (normalized.includes('gmina')) {
return 'gmina';
}
if (normalized.includes('miejscowosc')) {
return 'miejscowość';
}
if (normalized.includes('miasto') || city) {
return 'miasto';
}
return 'miejscowość';
}
function historyFindValuationCity(value: string) {
const normalized = normalizeHistoryText(value);
return Object.keys(VALUATION_PRICE_PER_SQM_BY_CITY).find((city) => {
const normalizedCity = normalizeHistoryText(city);
return normalized === normalizedCity || normalized.includes(normalizedCity) || normalizedCity.includes(normalized);
}) ?? null;
}
function historyFindKnownCity(value: string) {
const normalized = normalizeHistoryText(value);
return POLISH_CITIES.find((city) => {
const normalizedCity = normalizeHistoryText(city);
return normalized === normalizedCity || normalized.includes(normalizedCity) || normalizedCity.includes(normalized);
}) ?? null;
}
function historyFindRegion(value: string) {
const normalized = normalizeHistoryText(value);
return Object.keys(VALUATION_PRICE_PER_SQM_BY_REGION).find((region) => normalized.includes(normalizeHistoryText(region))) ?? null;
}
function estimateHistoryLocationPrice(label: string, sublabel = '') {
const locationText = [label, sublabel].filter(Boolean).join(', ');
const normalized = normalizeHistoryText(locationText);
const valuationCity = historyFindValuationCity(locationText);
const knownCity = valuationCity ?? historyFindKnownCity(locationText);
const region = historyFindRegion(locationText);
const locationType = historyLocationTypeFromText(locationText, knownCity);
const locationMultiplier = HISTORY_LOCATION_TYPE_MULTIPLIERS[locationType] ?? 0.78;
const seededAdjustment = 0.96 + (normalized.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 9) / 100;
if (valuationCity) {
const exactPrice = VALUATION_PRICE_PER_SQM_BY_CITY[valuationCity];
const cityMultiplier = locationType === 'dzielnica' ? 1 : locationType === 'miasto' ? 1 : locationMultiplier;
return {
price: Math.round((exactPrice * cityMultiplier * seededAdjustment) / 50) * 50,
region: region ?? 'Polska',
locationType,
};
}
const cityIndex = knownCity ? POLISH_CITIES.findIndex((city) => normalizeHistoryText(city) === normalizeHistoryText(knownCity)) : -1;
if (cityIndex >= 0) {
const regionalBase = region ? VALUATION_PRICE_PER_SQM_BY_REGION[region] : VALUATION_NATIONAL_BASE_PER_SQM;
const cityTierMultiplier = cityIndex < 8 ? 1.24 : cityIndex < 30 ? 1.12 : cityIndex < 80 ? 1.02 : 0.92;
return {
price: Math.round((regionalBase * cityTierMultiplier * seededAdjustment) / 50) * 50,
region: region ?? 'Polska',
locationType: 'miasto',
};
}
if (region) {
return {
price: Math.round((VALUATION_PRICE_PER_SQM_BY_REGION[region] * locationMultiplier * seededAdjustment) / 50) * 50,
region,
locationType,
};
}
const cityExact = Object.entries(HISTORY_CITY_BASE_PRICES).find(([city]) => normalizeHistoryText(city) === normalized || normalized.startsWith(`${normalizeHistoryText(city)} `));
if (cityExact) {
return { price: cityExact[1], region: 'Polska', locationType: 'miasto' };
}
return {
price: Math.round((VALUATION_NATIONAL_BASE_PER_SQM * locationMultiplier * seededAdjustment) / 50) * 50,
region: 'Polska',
locationType,
};
}
// Tylko dokladna nazwa lub alias ze slownika. Dopasowanie po samym miescie wybieralo pierwsza
// z brzegu dzielnice ("Warszawa" -> "Warszawa, Mokotów"), a historia cen jest liczona per dzielnica.
function findHistoryMarket(label: string) {
const normalized = normalizeHistoryText(label);
return HISTORY_PRICE_MARKETS.find((market) => {
const names = [market.label, market.city, ...(market.aliases ?? [])].map(normalizeHistoryText);
return names.some((name) => normalized === name || normalized.includes(name) || name.includes(normalized));
});
return HISTORY_PRICE_MARKETS.find((market) => (
[market.label, ...(market.aliases ?? [])].some((name) => normalizeHistoryText(name) === normalized)
));
}
const HISTORY_VOIVODESHIPS = [
'dolnośląskie', 'kujawsko-pomorskie', 'lubelskie', 'lubuskie', 'łódzkie', 'małopolskie', 'mazowieckie', 'opolskie',
'podkarpackie', 'podlaskie', 'pomorskie', 'śląskie', 'świętokrzyskie', 'warmińsko-mazurskie', 'wielkopolskie',
'zachodniopomorskie',
].map(normalizeHistoryText);
function isHistoryRegionName(value: string) {
const normalized = normalizeHistoryText(value.replace(/^województwo\s+/i, ''));
return normalized === 'polska' || HISTORY_VOIVODESHIPS.includes(normalized);
}
// Lokalizacja spoza slownika (wpisana albo z podpowiedzi mapy). Ceny liczy backend z ogloszen - tu tylko nazwy:
// "Kraków, Podgórze" -> dzielnica Podgórze w Krakowie; "Kraków" (+ województwo w opisie) -> cale miasto.
function createHistoryMarketFromPlace(label: string, sublabel = 'Polska'): PriceHistoryMarket {
const city = label.split(',')[0].trim() || label;
const estimate = estimateHistoryLocationPrice(label, sublabel);
const normalized = normalizeHistoryText(`${label}-${sublabel}`) || 'polska';
const seed = normalized.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
const sublabelParts = sublabel.split(',');
const sublabelTail = sublabelParts[sublabelParts.length - 1]?.trim();
const labelParts = label.split(',').map((part) => part.trim()).filter(Boolean);
const city = labelParts[0] || label.trim();
const district = labelParts.slice(1).find((part) => !isHistoryRegionName(part)) ?? '';
const regionFromPlace = sublabel.split(',').map((part) => part.trim()).find(isHistoryRegionName);
const knownRegion = HISTORY_PRICE_MARKETS.find((market) => normalizeHistoryText(market.city) === normalizeHistoryText(city))?.region;
const region = knownRegion
?? (regionFromPlace && normalizeHistoryText(regionFromPlace) !== 'polska' ? regionFromPlace.replace(/^województwo\s+/i, '') : 'Polska');
const normalized = normalizeHistoryText([city, district].filter(Boolean).join('-')) || 'polska';
return {
key: `custom-${normalized.replace(/\s+/g, '-')}`,
label: [label, sublabel.includes('województwo') ? '' : sublabelTail].filter(Boolean).join(', '),
label: [city, district].filter(Boolean).join(', '),
city,
region: estimate.region,
apartmentM2: Math.round(estimate.price * (0.98 + (seed % 5) / 100)),
trend24: 0.035 + (seed % 52) / 1000,
volatility: 0.02 + (seed % 22) / 1000,
region,
};
}
@@ -21221,7 +21225,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
const [selectedMarket, setSelectedMarket] = useState<PriceHistoryMarket | null>(null);
const [propertyType, setPropertyType] = useState<HistoryPropertyType>('Mieszkanie');
const [marketType, setMarketType] = useState<HistoryMarketType>('Wtórny i pierwotny');
const [period, setPeriod] = useState<HistoryPeriod>('2026');
const [period, setPeriod] = useState<HistoryPeriod>('5y');
const [createAlertFeedback, setCreateAlertFeedback] = useState<string | null>(null);
const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState<CityPriceAlertSubscription[]>([]);
const [suggestions, setSuggestions] = useState<PlaceSuggestion[]>([]);
@@ -21244,15 +21248,65 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
const hasSelectedMarket = selectedMarket != null;
const activeLocationLabel = selectedMarket?.label ?? 'wybranej lokalizacji';
const todayLabel = historyTodayLabel();
const monthLabels = historyMonthLabels();
// Historia cen nie ma jeszcze zrodla danych - wykres i statystyki zostaja puste do czasu podpiecia API.
const priceHistoryPoints = Array.from({ length: monthLabels.length }, () => 0);
const hasHistoryData = priceHistoryPoints.some((price) => price > 0);
const minPrice = Math.min(...priceHistoryPoints);
const maxPrice = Math.max(...priceHistoryPoints);
const tickValues = historyTicks(minPrice, maxPrice);
const chartValues = priceHistoryPoints;
const chartTicks = tickValues;
const historySlots = useMemo(() => buildHistorySlots(period), [period]);
// Historia z /api/price-history: dzienne zapisy srednich cen ofertowych z ogloszen serwisu
// oraz kwartalne ceny transakcyjne NBP i GUS dla calego miasta.
const [history, setHistory] = useState<ApiPriceHistory | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
// Aktualna srednia dla lokalizacji z porownania (klucz lokalizacji -> cena za m2 albo null).
const [comparisonPrices, setComparisonPrices] = useState<Record<string, number | null>>({});
const historyPropertyAvailable = HISTORY_PROPERTY_API[propertyType] != null;
// Nasza srednia w przedziale (miesiac albo kwartal); ostatni przedzial to swieza srednia z ogloszen.
// Przedzial bez zapisow zostaje pusty - niczego nie dopowiadamy.
const offerPrices: (number | null)[] = historySlots.map((slot, slotIndex) => {
if (!history) {
return null;
}
if (slotIndex === historySlots.length - 1 && history.current) {
return history.current.avgPricePerM2;
}
const inSlot = history.points.filter((point) => historySlotContains(slot, point.date));
return inSlot.length > 0
? Math.round(inSlot.reduce((sum, point) => sum + point.avgPricePerM2, 0) / inSlot.length)
: null;
});
const nbpPrices = historySlots.map((slot) => historyQuarterValue(history?.nbp ?? [], slot));
const gusPrices = historySlots.map((slot) => historyQuarterValue(history?.gus ?? [], slot));
const knownPrices = offerPrices
.map((price, slotIndex) => ({ price, slotIndex }))
.filter((entry): entry is { price: number; slotIndex: number } => entry.price != null);
const hasOfferData = knownPrices.length > 0;
const hasNbpData = nbpPrices.some((price) => price != null);
const hasGusData = gusPrices.some((price) => price != null);
const hasHistoryData = hasOfferData || hasNbpData || hasGusData;
const firstKnown = knownPrices[0] ?? null;
const latestKnown = knownPrices[knownPrices.length - 1] ?? null;
const lowestKnown = knownPrices.reduce<{ price: number; slotIndex: number } | null>((lowest, entry) => (!lowest || entry.price < lowest.price ? entry : lowest), null);
const highestKnown = knownPrices.reduce<{ price: number; slotIndex: number } | null>((highest, entry) => (!highest || entry.price > highest.price ? entry : highest), null);
const averageKnown = hasOfferData ? Math.round(knownPrices.reduce((sum, entry) => sum + entry.price, 0) / knownPrices.length) : null;
// Zmiana ma sens dopiero przy dwoch roznych przedzialach z danymi.
const changePercent = firstKnown && latestKnown && knownPrices.length > 1 && firstKnown.price > 0
? ((latestKnown.price - firstKnown.price) / firstKnown.price) * 100
: null;
// Ostatnie wartosci zrodel zewnetrznych (tez spoza wybranego okresu) - do porownania z ogloszeniami.
const latestNbp = history?.nbp?.[history.nbp.length - 1] ?? null;
const latestGus = history?.gus?.[history.gus.length - 1] ?? null;
const nbpMarketLabel = (history?.nbpMarket ?? 'Wtórny').toLowerCase();
// GUS podaje tez oba rynki razem - wtedy porownanie z NBP (jeden rynek) jest tylko orientacyjne.
const gusMarketLabel = marketType === 'Wtórny i pierwotny' ? 'oba rynki' : `rynek ${marketType.toLowerCase()}`;
// Najnowszy kwartal, dla ktorego sa oba zrodla - tam porownujemy NBP z GUS.
const commonExternalQuarter = [...(history?.nbp ?? [])].reverse()
.map((nbp) => ({ nbp, gus: history?.gus.find((gus) => gus.year === nbp.year && gus.quarter === nbp.quarter) }))
.find((pair): pair is { nbp: ApiQuarterPoint; gus: ApiQuarterPoint } => pair.gus != null) ?? null;
const quarterName = (point: ApiQuarterPoint) => `${HISTORY_ROMAN_QUARTERS[point.quarter - 1]} kw. ${point.year}`;
const percentDiff = (value: number, reference: number) => {
const diff = ((value - reference) / reference) * 100;
return `${diff >= 0 ? '+' : ''}${diff.toFixed(1).replace('.', ',')}%`;
};
const allValues = [...offerPrices, ...nbpPrices, ...gusPrices].filter((price): price is number => price != null);
const minPrice = allValues.length ? Math.min(...allValues) : 0;
const maxPrice = allValues.length ? Math.max(...allValues) : 0;
const chartTicks = historyTicks(minPrice, maxPrice);
const chartAxisMin = minPrice;
const chartAxisMax = maxPrice;
// Tabela porownania to ostatnie sprawdzone lokalizacje tego konta - bez podpowiedzi z listy.
@@ -21260,41 +21314,103 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
const chartWidth = 920;
const chartHeight = 250;
const chartPaddingTop = 86;
const chartPaddingRight = 22;
const chartPaddingBottom = 28;
const chartPaddingLeft = 52;
const chartInnerWidth = chartWidth - chartPaddingLeft - chartPaddingRight;
const chartInnerHeight = chartHeight - chartPaddingTop - chartPaddingBottom;
const pointCoords = chartValues.map((price, index) => {
const x = chartPaddingLeft + (index / (chartValues.length - 1)) * chartInnerWidth;
// Srodek kolumny przedzialu - w tym samym miejscu co podpis pod wykresem.
const slotX = (slotIndex: number) => ((slotIndex + 0.5) / historySlots.length) * chartWidth;
const priceY = (price: number) => {
const range = chartAxisMax - chartAxisMin || 1;
const y = chartPaddingTop + (1 - (price - chartAxisMin) / range) * chartInnerHeight;
return { x, y, price, sourcePrice: priceHistoryPoints[index] };
});
const linePath = pointCoords.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
return chartAxisMax === chartAxisMin
? chartPaddingTop + chartInnerHeight / 2
: chartPaddingTop + (1 - (price - chartAxisMin) / range) * chartInnerHeight;
};
const seriesCoords = (prices: (number | null)[]) => prices
.map((price, slotIndex) => (price == null ? null : { x: slotX(slotIndex), y: priceY(price), price, slotIndex }))
.filter((point): point is { x: number; y: number; price: number; slotIndex: number } => point != null);
const seriesPath = (coords: { x: number; y: number }[]) => coords
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
const pointCoords = seriesCoords(offerPrices);
const nbpCoords = seriesCoords(nbpPrices);
const gusCoords = seriesCoords(gusPrices);
const linePath = seriesPath(pointCoords);
const nbpPath = seriesPath(nbpCoords);
const gusPath = seriesPath(gusCoords);
const chartBaseY = chartHeight - chartPaddingBottom;
const areaPath = `${linePath} L ${pointCoords[pointCoords.length - 1].x.toFixed(1)} ${chartBaseY} L ${chartPaddingLeft} ${chartBaseY} Z`;
const hoveredPoint = hoveredHistoryIndex != null ? pointCoords[hoveredHistoryIndex] : null;
const hoveredDate = hoveredHistoryIndex != null ? `${monthLabels[hoveredHistoryIndex]} ${HISTORY_YEAR}${hoveredHistoryIndex === monthLabels.length - 1 ? ` (do ${todayLabel})` : ''}` : '';
const tooltipWidth = 132;
const tooltipHeight = 54;
const areaPath = pointCoords.length > 1
? `${linePath} L ${pointCoords[pointCoords.length - 1].x.toFixed(1)} ${chartBaseY} L ${pointCoords[0].x.toFixed(1)} ${chartBaseY} Z`
: '';
// Dymek: wszystkie zrodla dla przedzialu pod kursorem.
const hoveredSlot = hoveredHistoryIndex != null ? historySlots[hoveredHistoryIndex] ?? null : null;
const hoveredValues = hoveredHistoryIndex == null ? [] : [
{ label: 'Ogłoszenia', price: offerPrices[hoveredHistoryIndex] },
{ label: `NBP (${nbpMarketLabel})`, price: nbpPrices[hoveredHistoryIndex] },
{ label: `GUS (${gusMarketLabel})`, price: gusPrices[hoveredHistoryIndex] },
].filter((entry): entry is { label: string; price: number } => entry.price != null);
const hoveredPoint = hoveredSlot && hoveredValues.length > 0
? { x: slotX(hoveredHistoryIndex!), y: priceY(Math.max(...hoveredValues.map((entry) => entry.price))) }
: null;
const hoveredDate = hoveredSlot
? `${hoveredSlot.label}${hoveredHistoryIndex === historySlots.length - 1 ? ` (do ${todayLabel})` : ''}`
: '';
const tooltipWidth = 190;
const tooltipHeight = 30 + hoveredValues.length * 20;
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)
: false;
// Dymek przy najblizszym punkcie z danymi.
const handleHistoryChartMouseMove = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!hasSelectedMarket) {
const slotsWithData = historySlots
.map((_, slotIndex) => slotIndex)
.filter((slotIndex) => offerPrices[slotIndex] != null || nbpPrices[slotIndex] != null || gusPrices[slotIndex] != null);
if (!hasSelectedMarket || slotsWithData.length === 0) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
const svgX = ((event.clientX - bounds.left) / bounds.width) * chartWidth;
const rawIndex = Math.round(((svgX - chartPaddingLeft) / chartInnerWidth) * (pointCoords.length - 1));
const nextIndex = Math.min(pointCoords.length - 1, Math.max(0, rawIndex));
setHoveredHistoryIndex(nextIndex);
const nearest = slotsWithData.reduce((best, slotIndex) => (
Math.abs(slotX(slotIndex) - svgX) < Math.abs(slotX(best) - svgX) ? slotIndex : best
), slotsWithData[0]);
setHoveredHistoryIndex(nearest);
};
useEffect(() => {
if (!selectedMarket || !historyPropertyAvailable) {
setHistory(null);
return;
}
let cancelled = false;
setHistoryLoading(true);
setHoveredHistoryIndex(null);
apiFetch<ApiPriceHistory>(priceHistoryApiPath(selectedMarket, propertyType, marketType, historyFromDate(historySlots)))
.then((data) => { if (!cancelled) setHistory(data); })
.catch(() => { if (!cancelled) setHistory(null); })
.finally(() => { if (!cancelled) setHistoryLoading(false); });
return () => { cancelled = true; };
}, [selectedMarket, propertyType, marketType, historyPropertyAvailable, historySlots]);
// Porownanie: aktualna srednia dla kazdej zapamietanej lokalizacji (te same filtry co wykres).
useEffect(() => {
if (!historyPropertyAvailable || recentMarkets.length === 0) {
setComparisonPrices({});
return;
}
let cancelled = false;
const today = new Date().toISOString().slice(0, 10);
Promise.all(recentMarkets.map((market) => (
apiFetch<ApiPriceHistory>(priceHistoryApiPath(market, propertyType, marketType, today))
.then((data) => [market.key, data.current?.avgPricePerM2 ?? null] as const)
.catch(() => [market.key, null] as const)
))).then((entries) => {
if (!cancelled) {
setComparisonPrices(Object.fromEntries(entries));
}
});
return () => { cancelled = true; };
}, [recentMarkets, propertyType, marketType, historyPropertyAvailable]);
useEffect(() => {
if (!createAlertFeedback) {
return;
@@ -21384,11 +21500,11 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
return;
}
const typeParam = historyParams.get('typ');
if (typeParam && typeParam in HISTORY_PROPERTY_MULTIPLIERS) {
if (typeParam && typeParam in HISTORY_PROPERTY_API) {
setPropertyType(typeParam as HistoryPropertyType);
}
const marketParam = historyParams.get('rynek');
if (marketParam && marketParam in HISTORY_MARKET_MULTIPLIERS) {
if (marketParam && marketParam in HISTORY_MARKET_API) {
setMarketType(marketParam as HistoryMarketType);
}
applyMarket(resolveHistoryMarket(label));
@@ -21421,7 +21537,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
setSuggestOpen(false);
setPropertyType('Mieszkanie');
setMarketType('Wtórny i pierwotny');
setPeriod('2026');
setPeriod('5y');
};
const createCityAlert = () => {
@@ -21529,7 +21645,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
</label>
<label className="history-field">
<span>Rodzaj nieruchomości</span>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as HistoryPropertyType)}><option>Mieszkanie</option><option>Dom</option><option>Lokal użytkowy</option></select>
<select value={propertyType} onChange={(event) => setPropertyType(event.target.value as HistoryPropertyType)}><option>Mieszkanie</option><option>Dom</option></select>
</label>
<label className="history-field">
<span>Rynek</span>
@@ -21554,13 +21670,22 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
<div className="history-chart-head">
<div>
<h2>Średnia cena za {areaSuffix} w {activeLocationLabel}</h2>
<strong>—</strong>
<strong>{latestKnown ? formatPricePerArea(latestKnown.price) : '—'}</strong>
{changePercent != null && (
<p className={changePercent >= 0 ? 'positive' : 'negative'}>
{changePercent >= 0 ? '+' : ''}{changePercent.toFixed(1).replace('.', ',')}% <span>(od {historySlots[firstKnown!.slotIndex].sinceLabel})</span>
</p>
)}
<small className="history-source-note">
Średnie ceny ofertowe z ogłoszeń w serwisie Polska Lokalnie
{history?.trackedSince ? `, zapisywane codziennie od ${formatArticleDate(history.trackedSince)}` : ''}.
</small>
</div>
<span className="history-chart-mode">Cena za {areaSuffix}</span>
</div>
<div
className="history-chart-wrap empty"
className={`history-chart-wrap ${hasHistoryData ? '' : 'empty'}`}
onMouseMove={handleHistoryChartMouseMove}
onMouseLeave={() => setHoveredHistoryIndex(null)}
>
@@ -21584,27 +21709,58 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
const labelY = Math.max(13, Math.min(chartHeight - 10, y - 7));
return <g key={tick}><line x1="0" x2={chartWidth} y1={y} y2={y} /><text x="0" y={labelY}>{formatPricePerArea(tick)}</text></g>;
})}
<path className="history-area" d={areaPath} />
<path className="history-line" d={linePath} />
{pointCoords.map((point, index) => <circle className={`history-dot ${index === hoveredHistoryIndex ? 'active' : ''}`} cx={point.x} cy={point.y} r={index === hoveredHistoryIndex ? '6' : '4'} key={`${point.price}-${index}`} />)}
{nbpCoords.length > 1 && <path className="history-line history-line-nbp" d={nbpPath} />}
{gusCoords.length > 1 && <path className="history-line history-line-gus" d={gusPath} />}
{areaPath && <path className="history-area" d={areaPath} />}
{pointCoords.length > 1 && <path className="history-line" d={linePath} />}
{pointCoords.map((point) => <circle className={`history-dot ${point.slotIndex === hoveredHistoryIndex ? 'active' : ''}`} cx={point.x} cy={point.y} r={point.slotIndex === hoveredHistoryIndex ? '6' : '4'} key={`offer-${point.slotIndex}`} />)}
{/* Pojedynczy punkt zrodla zewnetrznego (np. okres 2026) - bez linii nie bylby widoczny. */}
{nbpCoords.length === 1 && <circle className="history-dot history-dot-nbp" cx={nbpCoords[0].x} cy={nbpCoords[0].y} r="4" />}
{gusCoords.length === 1 && <circle className="history-dot history-dot-gus" cx={gusCoords[0].x} cy={gusCoords[0].y} r="4" />}
</>
)}
{hasSelectedMarket && hoveredPoint && <line className="history-guide" x1={hoveredPoint.x} x2={hoveredPoint.x} y1={hoveredPoint.y} y2={chartBaseY} />}
{hasSelectedMarket && hoveredPoint && (
<foreignObject x={tooltipX} y={tooltipY} width={tooltipWidth} height={tooltipHeight + 8}>
<div className="history-tooltip"><span>{hoveredDate}</span><strong>{formatPricePerArea(hoveredPoint.sourcePrice)}</strong></div>
<div className="history-tooltip">
<span>{hoveredDate}</span>
{hoveredValues.map((entry) => (
<small key={entry.label}>{entry.label}: <strong>{formatPricePerArea(entry.price)}</strong></small>
))}
</div>
</foreignObject>
)}
</svg>
<div className="history-chart-empty"><strong>Brak danych</strong><span>Historia cen będzie dostępna wkrótce.</span></div>
<div className="history-months" style={{ gridTemplateColumns: `repeat(${monthLabels.length}, minmax(0, 1fr))` }}>{monthLabels.map((month, index) => <span key={`${month}-${index}`}>{month}</span>)}</div>
{!hasHistoryData && (
<div className="history-chart-empty">
{!hasSelectedMarket ? (
<><strong>Wybierz lokalizację</strong><span>Pokażemy średnie ceny ofertowe z ogłoszeń w wybranym mieście lub dzielnicy.</span></>
) : historyLoading ? (
<><strong>Wczytywanie…</strong><span>Pobieramy historię cen.</span></>
) : (
<><strong>Brak danych</strong><span>W tej lokalizacji nie ma jeszcze ogłoszeń spełniających wybrane filtry.</span></>
)}
</div>
)}
<div className="history-months" style={{ gridTemplateColumns: `repeat(${historySlots.length}, minmax(0, 1fr))` }}>{historySlots.map((slot) => <span key={slot.label}>{slot.axisLabel}</span>)}</div>
</div>
{(hasNbpData || hasGusData) && (
<div className="history-legend" aria-label="Legenda wykresu">
{hasOfferData && <span className="offer">Ceny ofertowe z ogłoszeń (Polska Lokalnie)</span>}
{hasNbpData && <span className="nbp">Ceny transakcyjne NBP, rynek {nbpMarketLabel} - całe miasto</span>}
{hasGusData && <span className="gus">Ceny transakcyjne GUS, {gusMarketLabel} - całe miasto</span>}
</div>
)}
<div className="history-stat-grid">
<article><span>Najniższa cena</span><strong>—</strong></article>
<article><span>Najwyższa cena</span><strong>—</strong></article>
<article><span>Zmiana od stycznia</span><strong>—</strong></article>
<article><span>Średnia cena</span><strong>—</strong></article>
<article><span>Najniższa cena</span><strong>{lowestKnown ? formatPricePerArea(lowestKnown.price) : '—'}</strong>{lowestKnown && <small>{historySlots[lowestKnown.slotIndex].label}</small>}</article>
<article><span>Najwyższa cena</span><strong>{highestKnown ? formatPricePerArea(highestKnown.price) : '—'}</strong>{highestKnown && <small>{historySlots[highestKnown.slotIndex].label}</small>}</article>
<article className={changePercent == null ? '' : changePercent >= 0 ? 'positive' : 'negative'}>
<span>Zmiana w okresie</span>
<strong>{changePercent == null ? '—' : `${changePercent >= 0 ? '+' : ''}${changePercent.toFixed(1).replace('.', ',')}%`}</strong>
{changePercent != null && firstKnown && latestKnown && <small>{formatPricePerArea(latestKnown.price - firstKnown.price)}</small>}
</article>
<article><span>Średnia cena</span><strong>{averageKnown != null ? formatPricePerArea(averageKnown) : '—'}</strong></article>
</div>
</section>
@@ -21626,7 +21782,74 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
<section className="history-insights-card">
<h2>Wnioski z analizy</h2>
<p className="history-side-empty">Brak danych do analizy.</p>
{!latestKnown && !latestNbp && !latestGus ? (
<p className="history-side-empty">
{hasSelectedMarket ? 'Brak danych do analizy w tej lokalizacji.' : 'Wybierz lokalizację, aby zobaczyć wnioski.'}
</p>
) : (
<div>
{latestKnown ? (
<>
<article>
<span><Icon name="chart" /></span>
<p>
<strong>Aktualna cena ofertowa</strong>
<small>
{propertyType} ({marketType.toLowerCase()}) w lokalizacji {activeLocationLabel}: {formatPricePerArea(latestKnown.price)}
{history?.current ? `, liczona z ${history.current.listingsCount} ${history.current.listingsCount === 1 ? 'ogłoszenia' : 'ogłoszeń'}` : ''}.
</small>
</p>
</article>
<article>
<span><Icon name="trend" /></span>
<p>
<strong>{changePercent == null ? 'Trend' : changePercent > 0 ? 'Ceny rosną' : changePercent < 0 ? 'Ceny spadają' : 'Ceny bez zmian'}</strong>
<small>
{changePercent == null
? `Za mało danych z ogłoszeń, aby ocenić trend${history?.trackedSince ? ` - zbieramy je od ${formatArticleDate(history.trackedSince)}` : ''}.`
: `Od ${historySlots[firstKnown!.slotIndex].sinceLabel} średnia zmieniła się o ${changePercent.toFixed(1).replace('.', ',')}%.`}
</small>
</p>
</article>
</>
) : (
<article>
<span><Icon name="chart" /></span>
<p>
<strong>Brak ogłoszeń</strong>
<small>W tej lokalizacji nie ma teraz ogłoszeń spełniających filtry - poniżej ceny transakcyjne z danych publicznych.</small>
</p>
</article>
)}
{(latestNbp || latestGus) && (
<article>
<span><Icon name="scale" /></span>
<p>
<strong>Porównanie źródeł (ceny transakcyjne, całe miasto)</strong>
{latestNbp && (
<small>
NBP, rynek {nbpMarketLabel}, {quarterName(latestNbp)}: {formatPricePerArea(latestNbp.pricePerM2)}
{latestKnown ? ` - ceny w ogłoszeniach ${percentDiff(latestKnown.price, latestNbp.pricePerM2)}` : ''}.
</small>
)}
{latestGus && (
<small>
GUS, {gusMarketLabel}, {quarterName(latestGus)}: {formatPricePerArea(latestGus.pricePerM2)}
{latestKnown ? ` - ceny w ogłoszeniach ${percentDiff(latestKnown.price, latestGus.pricePerM2)}` : ''}.
</small>
)}
{commonExternalQuarter && commonExternalQuarter.nbp.pricePerM2 !== commonExternalQuarter.gus.pricePerM2 && (
<small>
W {quarterName(commonExternalQuarter.nbp)} źródła się różnią: NBP ({nbpMarketLabel}) {formatPricePerArea(commonExternalQuarter.nbp.pricePerM2)},
GUS ({gusMarketLabel}) {formatPricePerArea(commonExternalQuarter.gus.pricePerM2)} ({percentDiff(commonExternalQuarter.nbp.pricePerM2, commonExternalQuarter.gus.pricePerM2)}).
Średnia z obu: {formatPricePerArea(Math.round((commonExternalQuarter.nbp.pricePerM2 + commonExternalQuarter.gus.pricePerM2) / 2))}.
</small>
)}
</p>
</article>
)}
</div>
)}
</section>
</div>
@@ -21643,7 +21866,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
onClick={() => applyMarket(market)}
>
<span>{historyShortMarketLabel(market)}</span>
<strong>—</strong>
<strong>{comparisonPrices[market.key] != null ? formatPricePerArea(comparisonPrices[market.key] as number) : '—'}</strong>
<i
className="history-compare-remove"
role="button"
+61
View File
@@ -8586,6 +8586,67 @@ a.city-card:hover {
stroke-width: 4;
}
/* Zrodla porownawcze: NBP (niebieska) i GUS (pomaranczowa, przerywana) - ceny transakcyjne. */
.history-line-nbp {
stroke: #2f6fd6;
stroke-width: 2.5;
}
.history-line-gus {
stroke: #e08a1e;
stroke-dasharray: 7 5;
stroke-width: 2.5;
}
.history-dot-nbp {
stroke: #2f6fd6;
}
.history-dot-gus {
stroke: #e08a1e;
}
.history-tooltip small {
color: #3f4d61;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.history-tooltip small strong {
font-size: 12px;
}
.history-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin: 4px 0 14px;
}
.history-legend span {
align-items: center;
color: #4b5a6e;
display: inline-flex;
font-size: 12px;
font-weight: 700;
gap: 7px;
}
.history-legend span::before {
border-top: 3px solid #0aa35f;
content: '';
width: 22px;
}
.history-legend .nbp::before {
border-top-color: #2f6fd6;
}
.history-legend .gus::before {
border-top: 3px dashed #e08a1e;
}
.history-guide {
stroke: #cfd9e5 !important;
stroke-dasharray: 5 5;