gendesign/frontend/src/components/site-finder/MarketTrendBlock.tsx
bot-backend 21608eced1
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 3m35s
Deploy / build-worker (push) Successful in 5m31s
Deploy / build-frontend (push) Successful in 7m23s
Deploy / deploy (push) Successful in 2m17s
fix(site-finder): три честности-фикса — B1 ГРС «выход №N» + околонуль + Росреестр-квартал (#2257)
2026-07-03 06:27:27 +00:00

481 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type {
MarketTrend,
MarketTrendStatus,
OfferTrend,
OfferTrendPoint,
} from "@/types/site-finder";
interface Props {
trend?: MarketTrend | null;
}
const LABEL_COLORS: Record<string, { bg: string; color: string }> = {
"Сильный рост": { 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.MMDD.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: цвет серии только для сравнения). Без зависимостей.
* Каждая точка — <title> с датой и ценой «₽/м²» (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)} /м²
</title>
</circle>
))}
</svg>
);
}
/**
* #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.MMDD.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 (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#ffffff",
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
{title}
</div>
{/* Main value: текущая медиана предложения + delta-бейдж за период */}
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{
fontSize: 24,
fontWeight: 800,
color: "#111827",
lineHeight: 1,
}}
>
{fmtPrice(latest.median_price_m2)} /м²
</span>
{delta != null && deltaStyle != null && (
<span
style={{
display: "inline-block",
borderRadius: 6,
padding: "2px 8px",
background: deltaStyle.bg,
color: deltaStyle.color,
fontSize: 13,
fontWeight: 700,
}}
>
{formatSignedPct(delta)}
</span>
)}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
медиана предложения · за период
</div>
{/* Мини-график динамики предложения */}
<div style={{ marginTop: 2 }}>
<OfferSparkline points={offer.points} />
</div>
{/* Источник полным предложением + caveat */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontSize: 11, color: "#6b7280" }}>{sourceLine}</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{caveat}</div>
</div>
</div>
);
}
/** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */
function TrendCaveat({ title, message }: { title: string; message: string }) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
{title}
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>{message}</div>
</div>
);
}
export function MarketTrendBlock({ trend }: Props) {
if (!trend) {
return (
<TrendCaveat
title="Тренд рынка"
message="Недостаточно сделок ДДУ в радиусе для тренда"
/>
);
}
// 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 (
<OfferOnlyCard
title={title}
offer={offer}
dealsStale={trend.deals_stale ?? false}
dealsAsOfDate={trend.deals_as_of_date}
/>
);
}
return (
<TrendCaveat
title={title}
message="Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."
/>
);
}
// #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 <TrendCaveat title={title} message={message} />;
}
// P2 (#1871): нет сделок в радиусе за период.
if (status === "no_deals") {
return (
<TrendCaveat title={title} message="Нет сделок в радиусе за период." />
);
}
// 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 (
<TrendCaveat
title={title}
message="Недостаточно сделок ДДУ в радиусе для тренда"
/>
);
}
// n < 30 — hide trend entirely, show data-insufficient state
if (n < N_WEAK) {
return (
<TrendCaveat
title={title}
message={`Недостаточно данных (n=${n}) — расширьте радиус для анализа тренда`}
/>
);
}
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 (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#fafafa",
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
{title}
</div>
{/* Main price */}
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
<span
style={{
fontSize: 24,
fontWeight: 800,
color: "#111827",
lineHeight: 1,
}}
>
{fmtPrice(trend.recent_avg_price_per_m2)} /м²
</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
за последние 6 мес · {trend.recent_deals_count} сделок
</div>
{/* Delta */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 16,
fontWeight: 700,
color: arrowColor,
}}
>
<span>{arrow}</span>
<span>
{delta > 0 ? "+" : ""}
{delta.toFixed(1)}%
</span>
</div>
{/* Prior comparison */}
<div style={{ fontSize: 11, color: "#9ca3af" }}>
vs {fmtPrice(trend.prior_avg_price_per_m2)} /м² за предыдущие 6 мес (
{trend.prior_deals_count}&rarr;{trend.recent_deals_count} сделок)
</div>
{/* Label badge */}
{label && (
<div
style={{
display: "inline-block",
borderRadius: 6,
padding: "3px 10px",
background: labelStyle.bg,
color: labelStyle.color,
fontSize: 12,
fontWeight: 600,
alignSelf: "flex-start",
marginTop: 2,
}}
>
{label}
</div>
)}
{/* #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 (
<div style={{ fontSize: 11, color: "#6b7280" }}>
Предложение: медиана {fmtPrice(latest.median_price_m2)} /м²
{deltaTxt} (Объектив)
</div>
);
})()}
{/* Weak data warning: 30 ≤ n < 100 */}
{n < N_STRONG && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 text-xs px-2 py-1 rounded">
Слабые данные (n={n}) расширьте радиус для точности
</div>
)}
</div>
);
}