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 (фолбэк по