feat(tradein/v2): позиция квартиры на рынке — плашка на карточке рекомендованной цены (#2899) #3024
4 changed files with 134 additions and 28 deletions
|
|
@ -37,6 +37,47 @@ const {
|
||||||
font,
|
font,
|
||||||
} = tokens;
|
} = tokens;
|
||||||
|
|
||||||
|
// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка
|
||||||
|
// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX
|
||||||
|
// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы
|
||||||
|
// молча.
|
||||||
|
function DeltaPill({
|
||||||
|
delta,
|
||||||
|
deltaLabel,
|
||||||
|
}: {
|
||||||
|
delta: string;
|
||||||
|
deltaLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 5,
|
||||||
|
background: infoSoftBg,
|
||||||
|
border: `1px solid ${lineSoft}`,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "3px 10px",
|
||||||
|
color: accentDeep,
|
||||||
|
fontFamily: font.sans,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: font.mono,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{delta}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
// Required on the app path (v2/page.tsx always supplies mapResultPanel
|
// Required on the app path (v2/page.tsx always supplies mapResultPanel
|
||||||
// output) — an omitted prop must be a TS error, not a silent fallback to
|
// output) — an omitted prop must be a TS error, not a silent fallback to
|
||||||
|
|
@ -419,6 +460,7 @@ export default function ResultPanel({
|
||||||
Подробнее <span>→</span>
|
Подробнее <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
) : card.bars ? (
|
) : card.bars ? (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -472,6 +514,14 @@ export default function ResultPanel({
|
||||||
Подробнее <span>→</span>
|
Подробнее <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
|
||||||
|
перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
|
||||||
|
{card.delta ? (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<DeltaPill delta={card.delta} deltaLabel={card.deltaLabel} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
||||||
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
||||||
|
|
@ -487,34 +537,7 @@ export default function ResultPanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.delta ? (
|
{card.delta ? (
|
||||||
<span
|
<DeltaPill delta={card.delta} deltaLabel={card.deltaLabel} />
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
gap: 5,
|
|
||||||
background: infoSoftBg,
|
|
||||||
border: `1px solid ${lineSoft}`,
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "3px 10px",
|
|
||||||
color: accentDeep,
|
|
||||||
fontFamily: font.sans,
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.delta}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
|
|
||||||
{card.deltaLabel}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// #2899 — позиция квартиры внутри когорты аналогов на карточке «РЕКОМЕНДОВАННАЯ
|
||||||
|
// ЦЕНА». Бэкенд считает market_percentile (1..99, null при когорте < 15); маппер
|
||||||
|
// обязан превратить его в лейбл по терцилям + подпись с числом и размером когорты,
|
||||||
|
// а при null — не рисовать ничего (не «В рынке» по умолчанию). На main карточка 1
|
||||||
|
// плашки не имеет вовсе — первый тест красный по значению (delta === undefined).
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mapResultPanel } from "../mappers";
|
||||||
|
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
|
||||||
|
function est(over: Partial<AggregatedEstimate>): AggregatedEstimate {
|
||||||
|
return { ...FIXTURE_ESTIMATE, n_analogs: 16, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function card1(e: AggregatedEstimate) {
|
||||||
|
return mapResultPanel(e).cards[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("#2899 market_percentile → плашка карточки 1", () => {
|
||||||
|
it("69-й перцентиль среди 16 аналогов → «Верх рынка» + честная подпись", () => {
|
||||||
|
const c = card1(est({ market_percentile: 69 }));
|
||||||
|
expect(c.delta).toBe("Верх рынка");
|
||||||
|
expect(c.deltaLabel).toBe("69-й перцентиль среди 16 аналогов");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("терцили: ≤33 — низ, 34..66 — в рынке, ≥67 — верх", () => {
|
||||||
|
expect(card1(est({ market_percentile: 20 })).delta).toBe("Низ рынка");
|
||||||
|
expect(card1(est({ market_percentile: 33 })).delta).toBe("Низ рынка");
|
||||||
|
expect(card1(est({ market_percentile: 34 })).delta).toBe("В рынке");
|
||||||
|
expect(card1(est({ market_percentile: 50 })).delta).toBe("В рынке");
|
||||||
|
expect(card1(est({ market_percentile: 66 })).delta).toBe("В рынке");
|
||||||
|
expect(card1(est({ market_percentile: 67 })).delta).toBe("Верх рынка");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("null / отсутствие поля → плашки нет; delta карточки 2 не тронут", () => {
|
||||||
|
const withNull = mapResultPanel(est({ market_percentile: null })).cards;
|
||||||
|
const legacy = mapResultPanel(est({})).cards;
|
||||||
|
expect(withNull[0].delta).toBeUndefined();
|
||||||
|
expect(withNull[0].deltaLabel).toBeUndefined();
|
||||||
|
expect(legacy[0].delta).toBeUndefined();
|
||||||
|
// контроль: плашка «к цене объявления» карточки 2 живёт своей жизнью
|
||||||
|
expect(withNull[1].delta).toBe(
|
||||||
|
mapResultPanel(FIXTURE_ESTIMATE).cards[1].delta,
|
||||||
|
);
|
||||||
|
expect(withNull[1].deltaLabel).toBe("к цене объявления");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("размер когорты в подписи берётся из n_analogs, склонение по числу", () => {
|
||||||
|
expect(
|
||||||
|
card1(est({ market_percentile: 50, n_analogs: 21 })).deltaLabel,
|
||||||
|
).toBe("50-й перцентиль среди 21 аналога");
|
||||||
|
expect(
|
||||||
|
card1(est({ market_percentile: 50, n_analogs: 23 })).deltaLabel,
|
||||||
|
).toBe("50-й перцентиль среди 23 аналогов");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1063,6 +1063,24 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
|
// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке
|
||||||
|
// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов
|
||||||
|
// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию).
|
||||||
|
// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»;
|
||||||
|
// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ
|
||||||
|
// location_index_pct (район против города) — тот живёт в HeroBar.
|
||||||
|
export function marketPositionPill(
|
||||||
|
e: Pick<AggregatedEstimate, "market_percentile" | "n_analogs">,
|
||||||
|
): { delta: string; deltaLabel: string } | undefined {
|
||||||
|
const pct = e.market_percentile;
|
||||||
|
if (pct == null || !Number.isFinite(pct)) return undefined;
|
||||||
|
const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке";
|
||||||
|
const n = e.n_analogs;
|
||||||
|
const forms: [string, string, string] = ["аналога", "аналогов", "аналогов"];
|
||||||
|
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, forms)}`;
|
||||||
|
return { delta, deltaLabel };
|
||||||
|
}
|
||||||
|
|
||||||
export function mapResultPanel(
|
export function mapResultPanel(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
streetDeals?: StreetDealsResponse | null,
|
streetDeals?: StreetDealsResponse | null,
|
||||||
|
|
@ -1109,6 +1127,9 @@ export function mapResultPanel(
|
||||||
// analogs into the left bins while the number says the outlier is dropped.
|
// analogs into the left bins while the number says the outlier is dropped.
|
||||||
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
|
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
|
||||||
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
||||||
|
// #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная
|
||||||
|
// плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null).
|
||||||
|
...(marketPositionPill(e) ?? {}),
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,10 @@ export interface AggregatedEstimate {
|
||||||
confidence: ConfidenceLevel;
|
confidence: ConfidenceLevel;
|
||||||
confidence_explanation: string | null;
|
confidence_explanation: string | null;
|
||||||
n_analogs: number;
|
n_analogs: number;
|
||||||
|
// #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
|
||||||
|
// аналогов дешевле». null/отсутствует = когорта < 15 либо старая оценка;
|
||||||
|
// показывать только вместе с n_analogs. НЕ location_index_pct (тот про район).
|
||||||
|
market_percentile?: number | null;
|
||||||
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
||||||
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
||||||
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue