fix(tradein/ui): «только этот дом» только когда выборка не расширялась соседями (#2583 M2)
Some checks failed
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s
Some checks failed
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала «История цен в этом доме · только этот дом», а рядом на том же экране стояло «в радиусе 300 м». Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый PriceHistoryChart). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b9c89641d1
commit
aefbd993bb
6 changed files with 100 additions and 9 deletions
|
|
@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
||||||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||||
{data.price_history.length >= 2 && (
|
{data.price_history.length >= 2 && (
|
||||||
<PriceHistoryChart points={data.price_history} />
|
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
|
||||||
)}
|
)}
|
||||||
{data.recent_sold.length > 0 && (
|
{data.recent_sold.length > 0 && (
|
||||||
<RecentSoldList items={data.recent_sold} />
|
<RecentSoldList items={data.recent_sold} />
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import {
|
||||||
import type { PriceHistoryYearPoint } from "@/types/trade-in";
|
import type { PriceHistoryYearPoint } from "@/types/trade-in";
|
||||||
import { sourcePublicLabel } from "@/lib/source-registry";
|
import { sourcePublicLabel } from "@/lib/source-registry";
|
||||||
|
|
||||||
type Props = { points: PriceHistoryYearPoint[] };
|
// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние
|
||||||
|
// в этом радиусе (M2 audit #2583).
|
||||||
|
type Props = { points: PriceHistoryYearPoint[]; radiusM: number };
|
||||||
|
|
||||||
const AVITO_LABEL = sourcePublicLabel("avito_imv");
|
const AVITO_LABEL = sourcePublicLabel("avito_imv");
|
||||||
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
|
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
|
||||||
|
|
@ -28,7 +30,7 @@ interface PivotRow {
|
||||||
n_yandex?: number;
|
n_yandex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriceHistoryChart({ points }: Props) {
|
export function PriceHistoryChart({ points, radiusM }: Props) {
|
||||||
const pivoted = useMemo<PivotRow[]>(() => {
|
const pivoted = useMemo<PivotRow[]>(() => {
|
||||||
const byYear: Record<number, PivotRow> = {};
|
const byYear: Record<number, PivotRow> = {};
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
|
|
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
|
||||||
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||||
<header style={{ marginBottom: 8 }}>
|
<header style={{ marginBottom: 8 }}>
|
||||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||||
История цен в этом доме
|
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
|
||||||
</h4>
|
</h4>
|
||||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||||
Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
Медиана ₽/м² по годам ·{" "}
|
||||||
|
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
|
||||||
|
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||||
</small>
|
</small>
|
||||||
</header>
|
</header>
|
||||||
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ export default function AnalyticsView({
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||||
История цен в этом доме
|
{data.priceHistory.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||||
{historyNote}
|
{historyNote}
|
||||||
|
|
@ -401,9 +401,9 @@ export default function AnalyticsView({
|
||||||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
|
aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
|
||||||
>
|
>
|
||||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
|
||||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||||
{PH_GRID_Y.map((y) => (
|
{PH_GRID_Y.map((y) => (
|
||||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была
|
||||||
|
// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до
|
||||||
|
// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом
|
||||||
|
// стояло «в радиусе 300 м» — две взаимоисключающие подписи.
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PriceHistoryChart } from "../../PriceHistoryChart";
|
||||||
|
import AnalyticsView from "../AnalyticsView";
|
||||||
|
import { mapAnalytics } from "../mappers";
|
||||||
|
import type {
|
||||||
|
HouseAnalyticsResponse,
|
||||||
|
PriceHistoryYearPoint,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
|
const points: PriceHistoryYearPoint[] = [
|
||||||
|
{ year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 },
|
||||||
|
{ year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function analytics(radius_m: number): HouseAnalyticsResponse {
|
||||||
|
return {
|
||||||
|
house_ids: [1, 2, 3],
|
||||||
|
radius_m,
|
||||||
|
price_history: points,
|
||||||
|
recent_sold: [],
|
||||||
|
kpi: {
|
||||||
|
total_lots: 7,
|
||||||
|
sold_count: 0,
|
||||||
|
sold_rate_pct: 0,
|
||||||
|
median_exposure_days: null,
|
||||||
|
median_bargain_pct: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("v2 история цен: подпись следует radius_m", () => {
|
||||||
|
it("выборка расширена соседями — не «только этот дом»", () => {
|
||||||
|
const { priceHistory } = mapAnalytics(analytics(300), null, null);
|
||||||
|
expect(priceHistory.title).toBe("История цен: дом и соседние");
|
||||||
|
expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м");
|
||||||
|
expect(priceHistory.note).not.toContain("только этот дом");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("экран v2 печатает подпись из данных, а не константу", () => {
|
||||||
|
render(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
|
||||||
|
expect(screen.getByText("История цен: дом и соседние")).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/в этом доме/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("выборка ровно по дому — прежняя подпись", () => {
|
||||||
|
const { priceHistory } = mapAnalytics(analytics(0), null, null);
|
||||||
|
expect(priceHistory.title).toBe("История цен в этом доме");
|
||||||
|
expect(priceHistory.note).toContain("только этот дом");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("старый экран: PriceHistoryChart", () => {
|
||||||
|
it("radiusM>0 — заголовок и подпись про соседей", () => {
|
||||||
|
// recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<PriceHistoryChart points={points} radiusM={300} />);
|
||||||
|
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
|
||||||
|
expect(screen.queryByText(/только этот дом/)).toBeNull();
|
||||||
|
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||||
|
// M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних
|
||||||
|
// домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране
|
||||||
|
// стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому.
|
||||||
|
const radiusM = a?.radius_m ?? 0;
|
||||||
|
const title =
|
||||||
|
radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме";
|
||||||
|
const scope =
|
||||||
|
radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом";
|
||||||
const empty: PriceHistory = {
|
const empty: PriceHistory = {
|
||||||
|
title,
|
||||||
note: "Нет данных по истории цен",
|
note: "Нет данных по истории цен",
|
||||||
years: [],
|
years: [],
|
||||||
avito: "",
|
avito: "",
|
||||||
|
|
@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
|
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
|
||||||
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
|
const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
|
||||||
totalLots,
|
totalLots,
|
||||||
["лот", "лота", "лотов"],
|
["лот", "лота", "лотов"],
|
||||||
)})`;
|
)})`;
|
||||||
|
|
@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
title,
|
||||||
note,
|
note,
|
||||||
years: yearsNum.map(String),
|
years: yearsNum.map(String),
|
||||||
avito: series("avito_imv"),
|
avito: series("avito_imv"),
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,8 @@ export interface SellTimeTier {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceHistory {
|
export interface PriceHistory {
|
||||||
|
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
|
||||||
|
title: string;
|
||||||
note: string;
|
note: string;
|
||||||
years: string[];
|
years: string[];
|
||||||
/** SVG polyline points string (viewBox 0 0 900 220). */
|
/** SVG polyline points string (viewBox 0 0 900 220). */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue