"use client"; import type { MarketTrend, MarketTrendStatus, OfferTrend, OfferTrendPoint, } from "@/types/site-finder"; interface Props { trend?: MarketTrend | null; } const LABEL_COLORS: Record = { "Сильный рост": { bg: "#dcfce7", color: "#15803d" }, "Умеренный рост": { bg: "#dbeafe", color: "#1d4ed8" }, Стагнация: { bg: "#fef3c7", color: "#b45309" }, Падение: { bg: "#fecaca", color: "#b91c1c" }, }; const N_WEAK = 30; const N_STRONG = 100; function trendArrow(delta: number): string { if (delta > 1) return "↑"; if (delta < -1) return "↓"; return "→"; } function deltaColor(delta: number): string { if (delta > 1) return "#15803d"; if (delta < -1) return "#b91c1c"; return "#6b7280"; } function fmtPrice(v: number): string { return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); } /** * Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01 * означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата * YYYY-MM-DD → «I квартал 2026» (римские I/II/III/IV по месяцу). null при * отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() — TZ). */ const ROMAN_QUARTERS = ["I", "II", "III", "IV"]; function formatDealsQuarter(asOf: string | undefined | null): string | null { if (!asOf) return null; const m = /^(\d{4})-(\d{2})/.exec(asOf); if (!m) return null; const year = Number(m[1]); const month = Number(m[2]); if (month < 1 || month > 12) return null; const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)]; return `${quarter} квартал ${year}`; } /** * #2178: дата YYYY-MM-DD → «DD.MM» или «DD.MM.YYYY» (при withYear). Парсим * компоненты напрямую — НЕ через new Date() (TZ-сдвиг на полночь UTC). */ function formatShortDate(iso: string, withYear: boolean): string | null { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); if (!m) return null; const [, year, month, day] = m; return withYear ? `${day}.${month}.${year}` : `${day}.${month}`; } /** #2178: «DD.MM–DD.MM.YYYY» для диапазона точек тренда (минус — Unicode en-dash). */ function formatPeriod(points: OfferTrendPoint[]): string | null { if (points.length === 0) return null; const first = formatShortDate(points[0].date, false); const last = formatShortDate(points[points.length - 1].date, true); if (first == null || last == null) return null; return `${first}–${last}`; } /** * #2178: знаковый delta-процент с Unicode-минусом (−, не дефис) и одним знаком. * `+8.1%` / `−8.1%` / `0.0%`. */ function formatSignedPct(delta: number): string { const rounded = delta.toFixed(1); if (delta > 0) return `+${rounded}%`; if (delta < 0) return `−${rounded.replace("-", "")}%`; return `${rounded}%`; } /** #2178: цвет delta-бейджа предложения — danger при падении, success при росте. */ function offerDeltaStyle(delta: number): { bg: string; color: string } { if (delta > 1) return { bg: "#dcfce7", color: "#15803d" }; if (delta < -1) return { bg: "#fecaca", color: "#b91c1c" }; return { bg: "#f3f4f6", color: "#374151" }; } /** * #2178: SVG-спарклайн по точкам медианы предложения. Одиночная линия — цвет * #374151 (правило ui-tokens: цвет серии только для сравнения). Без зависимостей. * Каждая точка — с датой и ценой «₽/м²» (native hover-tooltip с единицей). */ function OfferSparkline({ points }: { points: OfferTrendPoint[] }) { const W = 260; const H = 48; const PAD = 4; if (points.length < 2) return null; const values = points.map((p) => p.median_price_m2); const min = Math.min(...values); const max = Math.max(...values); const span = max - min || 1; const stepX = (W - PAD * 2) / (points.length - 1); const coords = points.map((p, i) => { const x = PAD + i * stepX; // Инвертируем Y: выше цена — выше на графике (меньше y). const y = PAD + (1 - (p.median_price_m2 - min) / span) * (H - PAD * 2); return { x, y }; }); const line = coords .map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`) .join(" "); return ( <svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Динамика медианной цены предложения, ₽/м²" style={{ display: "block", overflow: "visible" }} > <polyline points={line} fill="none" stroke="#374151" strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" /> {coords.map((c, i) => ( <circle key={i} cx={c.x} cy={c.y} r={2} fill="#374151"> <title> {formatShortDate(points[i].date, true) ?? points[i].date} ·{" "} {fmtPrice(points[i].median_price_m2)} ₽/м² ))} ); } /** * #2178: полноценная карточка тренда по предложению (Объектив) для status * "offer_only". Нейтральные токены (--fg-primary / --bg-card), НЕ выглядит * ошибкой. deals_stale управляет второй строкой-caveat. */ function OfferOnlyCard({ title, offer, dealsStale, dealsAsOfDate, }: { title: string; offer: OfferTrend; dealsStale: boolean; dealsAsOfDate: string | null | undefined; }) { const latest = offer.points[offer.points.length - 1]; const delta = offer.delta_pct; const period = formatPeriod(offer.points); const deltaStyle = delta != null ? offerDeltaStyle(delta) : null; // Источник — полным предложением: «По ценам предложения N ЖК рядом (Объектив, // M лотов) · DD.MM–DD.MM.YYYY». const lots = offer.lots_latest; const sourceParts = [ `По ценам предложения ${offer.complexes_covered} ЖК рядом`, lots != null ? `(Объектив, ${fmtPrice(lots)} лотов)` : "(Объектив)", ]; const sourceLine = sourceParts.join(" ") + (period != null ? ` · ${period}` : ""); // Caveat — почему показываем предложение вместо сделок. Сделки Росреестра // приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ // «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия. const dealsQuarter = formatDealsQuarter(dealsAsOfDate); const caveat = dealsStale ? dealsQuarter != null ? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).` : "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)." : "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив)."; return (
{title}
{/* Main value: текущая медиана предложения + delta-бейдж за период */}
{fmtPrice(latest.median_price_m2)} ₽/м² {delta != null && deltaStyle != null && ( {formatSignedPct(delta)} )}
медиана предложения · за период
{/* Мини-график динамики предложения */}
{/* Источник полным предложением + caveat */}
{sourceLine}
{caveat}
); } /** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */ function TrendCaveat({ title, message }: { title: string; message: string }) { return (
{title}
{message}
); } export function MarketTrendBlock({ trend }: Props) { if (!trend) { return ( ); } // P2 (#1871): отсутствие status трактуем как "ok" (старый/кэшированный ответ). const status: MarketTrendStatus = trend.status ?? "ok"; const radiusSuffix = trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : ""; const title = `Тренд рынка${radiusSuffix}`; // #2178: сделок в recent-окне нет (поквартальная поставка / нет рядом), но живёт // динамика предложения — полноценная карточка тренда по предложению (Объектив). if (status === "offer_only") { const offer = trend.offer_trend; // Defensive: без валидного offer-блока падаем в честный caveat, не крашимся. if (offer && offer.points.length > 0) { return ( ); } return ( ); } // #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало // квартала поставки), НЕ «устарели». Показываем честную квартальную подпись // вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете. if (status === "source_stale") { const quarter = formatDealsQuarter(trend.as_of_date); const message = trend.label ?? (quarter != null ? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.` : "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."); return ; } // P2 (#1871): нет сделок в радиусе за период. if (status === "no_deals") { return ( ); } // status === "ok" — старый контракт с полными числовыми полями. const n = trend.recent_deals_count; // Без числовых полей (defensive) — показываем no-data, не падаем. if ( trend.delta_6m_pct == null || trend.recent_avg_price_per_m2 == null || trend.prior_avg_price_per_m2 == null || trend.prior_deals_count == null ) { return ( ); } // n < 30 — hide trend entirely, show data-insufficient state if (n < N_WEAK) { return ( ); } const delta = trend.delta_6m_pct; const arrow = trendArrow(delta); const arrowColor = deltaColor(delta); // Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее // инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу. const label = trend.label ?? ""; const labelKey = label.split(" — ")[0]; const labelStyle = LABEL_COLORS[labelKey] ?? { bg: "#f3f4f6", color: "#374151", }; return (
{title}
{/* Main price */}
{fmtPrice(trend.recent_avg_price_per_m2)} ₽/м²
за последние 6 мес · {trend.recent_deals_count} сделок
{/* Delta */}
{arrow} {delta > 0 ? "+" : ""} {delta.toFixed(1)}%
{/* Prior comparison */}
vs {fmtPrice(trend.prior_avg_price_per_m2)} ₽/м² за предыдущие 6 мес ( {trend.prior_deals_count}→{trend.recent_deals_count} сделок)
{/* Label badge */} {label && (
{label}
)} {/* #2178: компактная вторая строка динамики предложения под сделко-трендом. */} {trend.offer_trend && trend.offer_trend.points.length > 0 && (() => { const offer = trend.offer_trend; const latest = offer.points[offer.points.length - 1]; const deltaTxt = offer.delta_pct != null ? ` · Δ ${formatSignedPct(offer.delta_pct)}` : ""; return (
Предложение: медиана {fmtPrice(latest.median_price_m2)} ₽/м² {deltaTxt} (Объектив)
); })()} {/* Weak data warning: 30 ≤ n < 100 */} {n < N_STRONG && (
Слабые данные (n={n}) — расширьте радиус для точности
)}
); }