From d73b9198d59367015ff8a04c990c79254bfbea41 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 23:31:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/v2):=20=D0=B2=D0=B8=D0=B7=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D0=B8=D0=B5=D1=80=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=8F/=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C/?= =?UTF-8?q?=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D0=BA=D0=BE=D0=BF=D0=B8=20(#2266?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после honest-hero раунда — фиксы: - H1: честный fallback-state флагманской карточки при expected_sold_price=null (вместо четырёх прочерков) - H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub - M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля "-N% к цене объявления", дисклеймер разброса читаем) - M4: PDF-отчёт primary после результата (без добавления lead-CTA — открытый продуктовый вопрос, не решался) - M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) — geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах - M6: gate немонотонного "срок продажи" при n<5 аналогов - M7: сброс scroll оверлея при смене секции - M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен", P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный "Обновлено: —", метро-разделители в адресе, убран внутренний "ПОВТОРНЫЕ АДРЕСА" label) - Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1 (accent → accentDeep, существующий токен той же палитры) Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR. Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался. Refs #2266, #2262 --- tradein-mvp/frontend/pnpm-lock.yaml | 3 + tradein-mvp/frontend/src/app/v2/page.tsx | 1 + .../components/trade-in/v2/AnalyticsView.tsx | 158 ++++++--- .../src/components/trade-in/v2/HeroBar.tsx | 55 ++- .../components/trade-in/v2/ObjectSummary.tsx | 15 +- .../components/trade-in/v2/ParamsPanel.tsx | 23 +- .../components/trade-in/v2/ResultPanel.tsx | 325 ++++++++++-------- .../components/trade-in/v2/SectionOverlay.tsx | 10 + .../components/trade-in/v2/SourcesView.tsx | 11 +- .../src/components/trade-in/v2/fixtures.ts | 2 +- .../src/components/trade-in/v2/mappers.ts | 112 +++--- .../src/components/trade-in/v2/types.ts | 8 +- 12 files changed, 459 insertions(+), 264 deletions(-) diff --git a/tradein-mvp/frontend/pnpm-lock.yaml b/tradein-mvp/frontend/pnpm-lock.yaml index 8a33c986..1eff3e29 100644 --- a/tradein-mvp/frontend/pnpm-lock.yaml +++ b/tradein-mvp/frontend/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: ^2.15.4 version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) devDependencies: + '@eslint/eslintrc': + specifier: ^3.0.0 + version: 3.3.5 '@types/node': specifier: ^22.0.0 version: 22.19.19 diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index df39dcf6..36e1d1ff 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -814,6 +814,7 @@ export default function TradeInV2Page() { key={estimate?.estimate_id ?? "new"} onSubmit={handleSubmit} isPending={mutation.isPending} + hasEstimate={hasEstimate} error={apiError} initialValues={initialValues} markers={markers} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx index ba54df7a..d8c15f00 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { tokens } from "./tokens"; import { analytics as ANALYTICS_FIXTURE } from "./fixtures"; +import { phYearX as histYearX } from "./mappers"; import type { Analytics, SellTimeTier } from "./types"; interface AnalyticsViewProps { @@ -36,8 +37,14 @@ function parsePoints(s: string): Pt[] { }); } -// X positions of the year axis labels (chart scaffolding, not in fixtures). -const histYearX = [50, 250, 460, 670, 860]; +// M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number +// of years, using the SAME geometry the mapper uses for the polyline vertices +// (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from +// ./mappers (single source of truth) rather than copy-pasted, so the two never +// drift apart. A fixed 5-slot array broke whenever the real series had ≠5 +// years: the extra labels fell back to an undefined x → x=0, so a late year +// (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits +// centred under its own vertex. // ---- sell-time tier tints -------------------------------------------------- // One-off decorative tints per variant (design lines 553-556). The soft border @@ -92,12 +99,39 @@ function sellTimeRangeMeaningful(tier: SellTimeTier): boolean { return true; } +// M6 — «Срок продажи в зависимости от цены» is not a real trend when built on +// thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн +// median) off just 3–6 analogs, i.e. noise read as a signal. We therefore GATE +// the chart honestly instead of faking monotonicity: +// • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust); +// • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced +// by a «мало данных» note (nothing meaningful left to compare). +const SELLTIME_MIN_N = 5; +const SELLTIME_MIN_TILES = 3; + +/** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */ +function sellTimeTierN(tier: SellTimeTier): number { + const n = Number.parseInt(tier.count, 10); + return Number.isFinite(n) ? n : 0; +} + +/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */ +function sellTimeTierValid(tier: SellTimeTier): boolean { + return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N; +} + export default function AnalyticsView({ data = ANALYTICS_FIXTURE, onNavigate, }: AnalyticsViewProps) { const [hoverHist, setHoverHist] = useState(false); + // M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs). + const sellTimeTiles = useMemo( + () => data.sellTime.filter(sellTimeTierValid), + [data.sellTime], + ); + // Price-history series + hover targets, derived from the data prop. Яндекс // only draws dots where its series diverges from Avito (последние 3 года). const avitoPts = useMemo( @@ -215,56 +249,73 @@ export default function AnalyticsView({ > {data.sellTimeNote} -
- {data.sellTime.map((tier, i) => { - const v = tierStyles[tier.variant]; - return ( -
-
- {tier.tier} -
+ {sellTimeTiles.length >= SELLTIME_MIN_TILES ? ( +
+ {sellTimeTiles.map((tier, i) => { + const v = tierStyles[tier.variant]; + return (
- {tier.days} +
+ {tier.tier} +
+
+ {tier.days} +
+
+ {sellTimeRangeMeaningful(tier) ? ( + <> + {tier.range} +
+ + ) : null} + {tier.count} +
-
- {sellTimeRangeMeaningful(tier) ? ( - <> - {tier.range} -
- - ) : null} - {tier.count} -
-
- ); - })} -
+ ); + })} +
+ ) : ( +
+ Мало данных для оценки чувствительности срока продажи к цене. +
+ )} {/* история цен */} @@ -339,9 +390,18 @@ export default function AnalyticsView({ ))} - + {data.priceHistory.years.map((yr, i) => ( - + {yr} ))} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index dd266b3d..10cd3e3b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -64,6 +64,17 @@ const pdfBtnStyle: CSSProperties = { transition: "all .15s", }; +// M4 — once a result is on screen the primary action is «СКАЧАТЬ PDF-ОТЧЁТ» +// (re-running the estimate is secondary), so the download becomes the single +// filled/primary button and «ОЦЕНИТЬ КВАРТИРУ» demotes to outline (ParamsPanel). +const pdfFilledStyle: CSSProperties = { + ...pdfBtnStyle, + background: `linear-gradient(90deg, ${tokens.accentDeep}, ${tokens.accent})`, + border: `1px solid ${tokens.accent}`, + color: tokens.onAccent, + boxShadow: "0 2px 10px rgba(46,139,255,.25)", +}; + export default function HeroBar({ data = HERO_FIXTURE, estimateId, @@ -71,22 +82,29 @@ export default function HeroBar({ onOpenInfo, }: HeroBarProps) { const pdfHref = pdfDownloadHref(estimateId); + // A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the + // filled/primary CTA (M4). Otherwise it stays a disabled outline. + const pdfFilled = Boolean(pdfHref); // Hide the building photo if the asset 404s/400s so the photoBg fill shows // instead of a broken-image icon. const [imgFailed, setImgFailed] = useState(false); - const pdfBtnInner = ( - <> - - - СКАЧАТЬ PDF-ОТЧЁТ - - - ); + const pdfBtnInner = (filled: boolean) => { + const frame = filled ? "#fff" : "#2e8bff"; + const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195"; + return ( + <> + + + СКАЧАТЬ PDF-ОТЧЁТ + + + ); + }; return (
{` .hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); } .hero-pdf-btn:active { transform: translateY(1px); } + .hero-pdf-btn-filled { transition: all .18s; } + .hero-pdf-btn-filled:hover { background: ${tokens.accentDeep}; box-shadow: 0 6px 22px rgba(46,139,255,.4); } + .hero-pdf-btn-filled:active { transform: translateY(1px); } .hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; } .hero-coef-row:hover { background: rgba(46,139,255,.09); } @keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } } @@ -197,13 +218,13 @@ export default function HeroBar({
{pdfHref ? ( - {pdfBtnInner} + {pdfBtnInner(pdfFilled)} ) : ( )} {hasEstimate && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index 39d3949d..e5bdd69b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -20,14 +20,13 @@ interface ObjectSummaryProps { // Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value // is built in mappers.mapSummary as "count · metric"; the metric is different -// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median -// (e.median_price_rub is area-normalised to the subject; these are raw medians -// of the analog/deal listing prices), so users couldn't tell what they were. -// This caption labels each ·-separated token token-for-token. Keyed by label -// content so it matches both the mapper labels ("Продажи рядом") and the older -// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary: +// per row. This caption labels each ·-separated token token-for-token. Keyed by +// label content so it matches both the mapper labels ("Продажи рядом") and the +// older fixture labels ("Продажи в доме"). Sources in mappers.mapSummary: // Продажи рядом : houseSold · kpi.median_exposure_days -// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub)) +// Аналоги : e.n_analogs · e.median_price_rub (H3 — SAME field as the +// 02-РЕЗУЛЬТАТ hero «медиана объявлений», not the raw analog +// listing-price median which read far higher over the same set) // Сделки : dealCount · streetDeals.median_price_rub ?? median(deals) // Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct function rowHint(label: string): string { @@ -356,7 +355,7 @@ export function ObjectSummary({ lineHeight: 1.7, }} > - CV + Разброс цен diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index e25e04eb..767cabd3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -313,6 +313,10 @@ const styles = ` .pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)} .pp-eval-btn:active{transform:translateY(1px)} .pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)} +.pp-eval-btn-secondary{background:transparent;transition:all .18s} +.pp-eval-btn-secondary:hover{background:rgba(46,139,255,.08)} +.pp-eval-btn-secondary:active{transform:translateY(1px)} +.pp-eval-btn-secondary:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)} `; // Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user @@ -457,6 +461,9 @@ interface ParamsPanelProps { onSubmit?: (input: TradeInEstimateInput) => void; /** Disables the button + shows a pending label while the estimate is running. */ isPending?: boolean; + /** M4 — once a result is on screen the primary CTA moves to «СКАЧАТЬ PDF-ОТЧЁТ» + * (HeroBar), so «ОЦЕНИТЬ КВАРТИРУ» (a re-run) demotes to a secondary outline. */ + hasEstimate?: boolean; /** Server-side error to surface inline (validation errors are handled locally). */ error?: string | null; /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ @@ -499,10 +506,14 @@ function initRadiusLabel(radiusM: number | null | undefined): string { export default function ParamsPanel({ onSubmit, isPending = false, + hasEstimate = false, error = null, initialValues, markers = [], }: ParamsPanelProps) { + // M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ» + // (HeroBar); this button (a re-run) demotes to a secondary outline. + const secondaryCta = hasEstimate; const [openDd, setOpenDd] = useState(null); // РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id). const radiusListId = useId(); @@ -1569,10 +1580,10 @@ export default function ParamsPanel({
- {/* button */} + {/* button — filled/primary before a result, outline/secondary after (M4). */} + ) : card.bars ? (
) : ( - <> + // M2 — calm delta pill (was a 51px circular gauge that read like a + // tech "занижение" indicator). Full text «−18% к цене объявления», + // neutral accentDeep on a soft-blue chip, tabular-nums. +
+ {card.delta ? ( + + + {card.delta} + + + {card.deltaLabel} + + + ) : ( + + )} -
- -
- - {card.delta} - - - {card.deltaLabel} - -
-
- +
)} ); @@ -464,9 +515,11 @@ export default function ResultPanel({ не читался как «неуверенность расчёта». */}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx index 48ed5f07..1063e6b9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx @@ -50,10 +50,19 @@ export default function SectionOverlay({ // effect (deps []) never re-runs / re-traps focus on parent re-renders even // though page.tsx passes a fresh arrow each render. const dialogRef = useRef(null); + const bodyRef = useRef(null); const lastFocused = useRef(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + // M7 — the overlay body is ONE scroller shared by every section view; switching + // sections (nav within the overlay) used to inherit the previous section's + // scroll offset, dropping the user into the middle of the new view. Reset to the + // top whenever the active section changes. + useEffect(() => { + if (bodyRef.current) bodyRef.current.scrollTop = 0; + }, [active]); + useEffect(() => { const dialog = dialogRef.current; // Remember the trigger, then move focus into the dialog. @@ -294,6 +303,7 @@ export default function SectionOverlay({ {/* overlay body */}
[] = [ { key: "price", fr: "1.1fr", - head: СТОИМОСТЬ, + // §M8 — унификация терминологии: везде «цена» (был «СТОИМОСТЬ»). + head: ЦЕНА, cell: (r) => ( {r.price} @@ -475,7 +476,7 @@ export default function SourcesView({
- Обновлено: —
+ {/* §M8 — литеральный «Обновлено: —» скрыт (нет реального значения). */} Источников:{" "} {data.sourceCount ?? "—"}
@@ -493,9 +494,11 @@ export default function SourcesView({ unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`} /> diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index 6e127d65..ad68e530 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -624,7 +624,7 @@ export const dropdownOptions: DropdownOptions = { export const locationFactors: LocationFactors = { coef: "0.87", intro: - "Показывает, как адрес корректирует стоимость относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", + "Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" }, positives: [ { label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" }, 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 b9724fdc..fd21f7b1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -262,12 +262,15 @@ function coeffVar(values: number[]): number | null { return (Math.sqrt(variance) / mean) * 100; } +// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of +// variation) is not surfaced to users anymore, and the витрина rounds it to a +// whole percent ("42%", not "42,2%") — the extra decimal implied a false +// precision for what is a rough spread indicator. function cvStr(values: number[]): string { const cv = coeffVar(values); return cv != null ? `${cv.toLocaleString("ru-RU", { - minimumFractionDigits: 1, - maximumFractionDigits: 1, + maximumFractionDigits: 0, })}%` : "—"; } @@ -857,6 +860,14 @@ export function mapResultPanel( ): ResultPanelData { const dealTier = resolveDealTier(e, streetDeals); + // H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of + // bare «—» when the model has no expected-sold prediction. When it is missing + // we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta; + // the view then moves the accent frame onto the «рекомендованная цена» card. + const hasExpected = + e.expected_sold_price_rub != null && + Number.isFinite(e.expected_sold_price_rub); + const cards: ResultCard[] = [ { title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"], @@ -869,21 +880,29 @@ export function mapResultPanel( }, { title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"], - value: fmtMln(e.expected_sold_price_rub), + value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—", unit: "млн ₽", - range: rangeLine( - e.expected_sold_range_low_rub, - e.expected_sold_range_high_rub, - ), - ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`, + range: hasExpected + ? rangeLine( + e.expected_sold_range_low_rub, + e.expected_sold_range_high_rub, + ) + : "", + ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", delta: - e.asking_to_sold_ratio != null + hasExpected && e.asking_to_sold_ratio != null ? fmtPct((e.asking_to_sold_ratio - 1) * 100) - : "—", - // §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса. - // «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»). - deltaLabel: "К ОБЪЯВЛ.", + : undefined, + // §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка + // сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо + // обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем. + deltaLabel: "к цене объявления", + // H1 empty-state message (only when there is no expected-sold prediction). + emptyNote: hasExpected + ? undefined + : "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении", note: + hasExpected && dealTier != null && e.expected_sold_price_rub != null && Number.isFinite(e.expected_sold_price_rub) && @@ -972,11 +991,18 @@ export function mapSummary( ? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}` : "—"; - const analogMedian = median(e.analogs.map((a) => a.price_rub)); + // H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ + // headline (e.median_price_rub, the subject-area-normalised median). It used to + // show median(analogs.price_rub) — the RAW median of the analog LISTING totals + // across mixed areas — which for a small subject reads far higher (e.g. 14,23 + // млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two + // contradictory medians on one screen. Now both surfaces read the same field. const row2Value = e.n_analogs > 0 ? `${e.n_analogs}${ - analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : "" + Number.isFinite(e.median_price_rub) + ? ` · ${fmtMln(e.median_price_rub)} млн` + : "" }` : "—"; @@ -1204,7 +1230,17 @@ function dealMeta(l: AnalogLot): string { * touches the comma-splitting parseAddress. */ function deglueAddr(addr: string): string { - return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2"); + return ( + addr + .replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2") + // §M8 — metro proximity glued straight into the address with no spaces + // ("Геологическая11–15 мин.Чкаловская16–20"): (a) split a Cyrillic word + // from a following digit ("Геологическая11" → "Геологическая 11"), and + // (b) separate a "мин." run from the next station name with " · " + // ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only. + .replace(/([а-яё])(\d)/g, "$1 $2") + .replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2") + ); } // ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ───────────────────────────────────────── @@ -1393,8 +1429,11 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] { // [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band) // so a narrow series like 120–160k fills the plot instead of flat-lining against // a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines. -const PH_X_LEFT = 58; -const PH_X_RIGHT = 885; +// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels +// (M5) must share this EXACT geometry with the polyline vertices below, or a +// label drifts off its own dot. Single source of truth, no copy-pasted consts. +export const PH_X_LEFT = 58; +export const PH_X_RIGHT = 885; const PH_Y_TOP = 20; // top gridline pixel (high value) const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value) const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175 @@ -1403,7 +1442,7 @@ const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom) // the union of their finite medians so the two series share one axis. const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; -function phYearX(i: number, n: number): number { +export function phYearX(i: number, n: number): number { if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2; return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1); } @@ -1802,7 +1841,9 @@ export function mapSources( kpi: { count: dealCount > 0 ? String(dealCount) : "—", median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—", - delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—", + // §M8 — единая формулировка дельт: всё считается «относительно цен + // объявлений» (deltaPct = медиана сделок / медиана объявлений − 1). + delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—", range: mlnRangeSp(dealLo, dealHi), }, }; @@ -1887,8 +1928,8 @@ function cacheStatus(iso: string | null | undefined): { * 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на * фронте из per-user /history (null → пустая таблица + KPI с "—"). * rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1). - * kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн) · - * ПОВТОРНЫЕ АДРЕСА (доля переоценок = (всего − уникальных)/всего). + * kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн). + * «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика). */ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { const list = history ?? []; @@ -1896,7 +1937,10 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { const rows: CacheRow[] = list.map((h) => { const { status, statusColor } = cacheStatus(h.created_at); return { - addr: h.address || "—", + // §M8 — de-glue metro/address runs the same way as the analog/deal rows + // (these are the user's own past-estimate addresses, often geocoded with + // metro proximity jammed on with no separators). + addr: h.address ? deglueAddr(h.address) : "—", time: fmtCacheTime(h.created_at), src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно status, @@ -1916,17 +1960,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { ? prices.reduce((a, b) => a + b, 0) / prices.length : null; - // KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов. - const seen = new Map(); - for (const h of list) { - const key = (h.address ?? "").trim().toLowerCase(); - if (!key) continue; - seen.set(key, (seen.get(key) ?? 0) + 1); - } - const withAddr = [...seen.values()].reduce((a, b) => a + b, 0); - const dupPct = - withAddr > 0 ? ((withAddr - seen.size) / withAddr) * 100 : null; - + // §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no + // meaning for the end user, so it is dropped from this витрина. Only the two + // user-relevant KPIs remain. const kpis: CacheKpi[] = [ { label: "ВСЕГО ОЦЕНОК", @@ -1939,14 +1975,6 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { unit: avg != null ? "млн ₽" : undefined, sub: "по проведённым оценкам", }, - { - // fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" — - // для доли это неверно; рендерим число + unit "%" (как в дизайне). - label: "ПОВТОРНЫЕ АДРЕСА", - value: dupPct != null ? String(Math.round(dupPct)) : "—", - unit: dupPct != null ? "%" : undefined, - sub: "доля переоценок", - }, ]; return { rows, kpis }; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 45a83eac..15de1a55 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -38,12 +38,16 @@ export interface ResultCard { ppm: string; /** Mini-histogram column heights in % (cards 1 & 3). */ bars?: number[]; - /** Donut delta value, e.g. "−11%" (card 2). */ + /** Delta value, e.g. "−18%" (card 2). Rendered as a calm pill, not a gauge. */ delta?: string; - /** Donut caption, e.g. "К РЫНКУ" (card 2). */ + /** Delta pill caption, e.g. "к цене объявления" (card 2). */ deltaLabel?: string; /** Reconciliation note under the value (card 2: expected price vs ДКП median). */ note?: string; + /** H1 empty-state message: when set (card 2, no expected-sold prediction) the + * card renders this instead of value/range/ppm/delta, and the panel moves the + * accent frame onto the «рекомендованная цена» card. */ + emptyNote?: string; /** Nav index opened by the card's "Подробнее →" link. */ nav: number; }