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
+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;