diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index 80895489..889cf35b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -37,6 +37,47 @@ const { font, } = tokens; +// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка +// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX +// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы +// молча. +function DeltaPill({ + delta, + deltaLabel, +}: { + delta: string; + deltaLabel?: string; +}) { + return ( + + + {delta} + + {deltaLabel} + + ); +} + interface ResultPanelProps { // 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 @@ -419,6 +460,7 @@ export default function ResultPanel({ Подробнее ) : card.bars ? ( + <>
+ {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й + перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */} + {card.delta ? ( +
+ +
+ ) : null} + ) : ( // M2 — calm delta pill (was a 51px circular gauge that read like a // tech "занижение" indicator). Full text «−18% к цене объявления», @@ -487,34 +537,7 @@ export default function ResultPanel({ }} > {card.delta ? ( - - - {card.delta} - - - {card.deltaLabel} - - + ) : ( )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts new file mode 100644 index 00000000..f1bd46e2 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/marketPercentile.test.ts @@ -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 { + 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 аналогов"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 777918d1..72e45266 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1063,6 +1063,24 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] { } /** 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, +): { 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( e: AggregatedEstimate, streetDeals?: StreetDealsResponse | null, @@ -1109,6 +1127,9 @@ export function mapResultPanel( // analogs into the left bins while the number says the outlier is dropped. // dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn). bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), + // #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная + // плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null). + ...(marketPositionPill(e) ?? {}), nav: 2, }, { diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index c136e129..9de256e9 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -183,6 +183,10 @@ export interface AggregatedEstimate { confidence: ConfidenceLevel; confidence_explanation: string | null; 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 (нет данных) // fix (never-block estimate) — оценка теперь показывается всегда, пока цена // посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по