fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
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) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s

Живой визуальный аудит /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
This commit is contained in:
bot-backend 2026-07-03 23:31:50 +03:00
parent 3945060f0c
commit d73b9198d5
12 changed files with 459 additions and 264 deletions

View file

@ -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

View file

@ -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}

View file

@ -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 36 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,14 +249,15 @@ export default function AnalyticsView({
>
{data.sellTimeNote}
</div>
{sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4,1fr)",
gridTemplateColumns: `repeat(${sellTimeTiles.length},1fr)`,
gap: 12,
}}
>
{data.sellTime.map((tier, i) => {
{sellTimeTiles.map((tier, i) => {
const v = tierStyles[tier.variant];
return (
<div
@ -251,7 +286,11 @@ export default function AnalyticsView({
{tier.days}
</div>
<div
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
style={{
fontSize: 9.5,
color: tokens.hint,
lineHeight: 1.5,
}}
>
{sellTimeRangeMeaningful(tier) ? (
<>
@ -265,6 +304,18 @@ export default function AnalyticsView({
);
})}
</div>
) : (
<div
style={{
fontSize: 11,
color: tokens.muted2,
lineHeight: 1.5,
padding: "6px 0 2px",
}}
>
Мало данных для оценки чувствительности срока продажи к цене.
</div>
)}
</div>
{/* история цен */}
@ -339,9 +390,18 @@ export default function AnalyticsView({
</text>
))}
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.priceHistory.years.map((yr, i) => (
<text key={yr} x={histYearX[i]} y={214}>
<text
key={yr}
x={histYearX(i, data.priceHistory.years.length)}
y={214}
>
{yr}
</text>
))}

View file

@ -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 = (
const pdfBtnInner = (filled: boolean) => {
const frame = filled ? "#fff" : "#2e8bff";
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
return (
<>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
<line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
</svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
СКАЧАТЬ PDF-ОТЧЁТ
</span>
</>
);
};
return (
<div
@ -100,6 +118,9 @@ export default function HeroBar({
<style>{`
.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({
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
{pdfHref ? (
<a
className="hero-pdf-btn"
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
href={pdfHref}
target="_blank"
rel="noopener noreferrer"
style={pdfBtnStyle}
style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
>
{pdfBtnInner}
{pdfBtnInner(pdfFilled)}
</a>
) : (
<button
@ -212,7 +233,7 @@ export default function HeroBar({
title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
>
{pdfBtnInner}
{pdfBtnInner(false)}
</button>
)}
{hasEstimate && (

View file

@ -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,
}}
>
<span>CV</span>
<span>Разброс цен</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
>

View file

@ -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<DdKey>(null);
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId();
@ -1569,10 +1580,10 @@ export default function ParamsPanel({
</div>
</div>
{/* button */}
{/* button — filled/primary before a result, outline/secondary after (M4). */}
<button
type="button"
className="pp-eval-btn"
className={secondaryCta ? "pp-eval-btn-secondary" : "pp-eval-btn"}
onClick={handleSubmit}
disabled={isPending}
style={{
@ -1588,7 +1599,7 @@ export default function ParamsPanel({
cursor: isPending ? "wait" : "pointer",
opacity: isPending ? 0.6 : 1,
fontFamily: tokens.font.sans,
color: tokens.onAccent,
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
flex: "0 0 auto",
}}
>
@ -1604,9 +1615,11 @@ export default function ParamsPanel({
justifyContent: "center",
width: 26,
height: 26,
border: "1px solid rgba(255,255,255,.7)",
border: `1px solid ${
secondaryCta ? tokens.accent : "rgba(255,255,255,.7)"
}`,
borderRadius: "50%",
color: tokens.onAccent,
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
}}
>

View file

@ -14,7 +14,9 @@ import type { ResultPanelData } from "./mappers";
const {
accent,
accentDeep,
ink,
body,
body2,
muted,
muted2,
@ -158,7 +160,7 @@ export default function ResultPanel({
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
</span>
<span>
CV{" "}
РАЗБРОС ЦЕН{" "}
<b style={{ color: accent, fontFamily: font.mono }}>
{data.meta.cv}
</b>
@ -176,17 +178,24 @@ export default function ResultPanel({
}}
>
{data.cards.map((card, ci) => {
// Price hierarchy (audit H2). The three cards are NOT equal weight:
// Price hierarchy (audit H2 + M1). The three cards are NOT equal weight:
// ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~43% → demoted)
// The headline gets an accent border + tint + glow + larger accent
// The headline gets an accent border + tint + glow + a larger accent
// value so the user can't confuse it with the registry figure; the
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
// tile. All three values stay (honesty); wiring (mapResultPanel) is
// untouched — emphasis is presentation only.
const isHeadline = ci === 1;
// tile. Value scale reads expected(40) ≫ asking(30) > ДКП(28).
//
// H1 — when the expected card has no prediction (emptyNote), it drops
// to an honest empty-state and the accent frame moves onto the asking
// card (ci 0) so the panel always has ONE clear headline, never a hole.
const expectedEmpty = Boolean(data.cards[1]?.emptyNote);
const isEmptyCard = Boolean(card.emptyNote);
const isRegistry = ci === 2;
const headlineIndex = expectedEmpty ? 0 : 1;
const isHeadline = !isEmptyCard && ci === headlineIndex;
const isAsking = ci === 0;
return (
<div
key={ci}
@ -225,7 +234,12 @@ export default function ResultPanel({
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: isHeadline ? accent : isRegistry ? muted2 : body2,
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
// even the AA "large text" floor (3:1). accentDeep is the same
// brand hue, darkened (existing token, not a one-off hex) — it
// clears ~4.2:1 here, so the label reads reliably too.
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6,
}}
@ -241,9 +255,26 @@ export default function ResultPanel({
marginTop: 3,
}}
>
СПРАВОЧНО
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
</div>
)}
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» just an
honest note pointing at the recommended asking price. */}
{isEmptyCard ? (
<div
style={{
marginTop: 12,
fontSize: 11,
lineHeight: 1.5,
color: body,
maxWidth: "94%",
}}
>
{card.emptyNote}
</div>
) : (
<>
<div
style={{
marginTop: 14,
@ -255,10 +286,20 @@ export default function ResultPanel({
<span
style={{
fontFamily: font.mono,
fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
// yet clearly below the accent headline (40).
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1,
color: isHeadline ? accent : isRegistry ? muted : undefined,
// Contrast follow-up (audit #2266) — accentDeep, not
// accent, for the same reason as the title label above.
color: isHeadline
? accentDeep
: isRegistry
? muted
: isAsking
? ink
: undefined,
}}
>
{card.value}
@ -274,6 +315,7 @@ export default function ResultPanel({
{card.unit}
</span>
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
@ -284,6 +326,8 @@ export default function ResultPanel({
>
{card.range}
</div>
)}
{card.ppm && (
<div
style={{
fontFamily: font.mono,
@ -294,6 +338,7 @@ export default function ResultPanel({
>
{card.ppm}
</div>
)}
{card.note && (
<div
style={{
@ -307,8 +352,36 @@ export default function ResultPanel({
{card.note}
</div>
)}
</>
)}
{card.bars ? (
{isEmptyCard ? (
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
width: "100%",
background: "none",
border: "none",
padding: 0,
margin: 0,
marginTop: 16,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
) : card.bars ? (
<div
style={{
display: "flex",
@ -363,7 +436,50 @@ export default function ResultPanel({
</button>
</div>
) : (
<>
// 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.
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 14,
}}
>
{card.delta ? (
<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,
}}
>
{card.delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
{card.deltaLabel}
</span>
</span>
) : (
<span />
)}
<button
type="button"
className="rp-more"
@ -372,87 +488,22 @@ export default function ResultPanel({
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
width: "100%",
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
marginTop: 16,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
<div
style={{
position: "absolute",
right: 14,
top: 13,
width: 58,
height: 58,
}}
>
<svg width="58" height="58" viewBox="0 0 58 58" aria-hidden="true">
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={track}
strokeWidth="3"
/>
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="157"
strokeDashoffset="140"
transform="rotate(-90 29 29)"
/>
</svg>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 13,
fontWeight: 500,
color: accent,
}}
>
{card.delta}
</span>
<span
style={{
fontSize: 6.5,
letterSpacing: 1,
color: muted2,
marginTop: 1,
}}
>
{card.deltaLabel}
</span>
</div>
</div>
</>
)}
</div>
);
@ -464,9 +515,11 @@ export default function ResultPanel({
не читался как «неуверенность расчёта». */}
<div
style={{
fontSize: 10,
// M3 — было 10px muted-микропринт; повышено до 12px body, чтобы
// ключевая honest-оговорка о разбросе читалась, а не терялась.
fontSize: 12,
lineHeight: 1.5,
color: hint,
color: body,
marginTop: -4,
}}
>

View file

@ -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<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(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 */}
<div
ref={bodyRef}
style={{
flex: 1,
minHeight: 0,

View file

@ -228,7 +228,8 @@ const AD_COLS: Col<AdRowData>[] = [
{
key: "price",
fr: "1.1fr",
head: <span role="columnheader">СТОИМОСТЬ</span>,
// §M8 — унификация терминологии: везде «цена» (был «СТОИМОСТЬ»).
head: <span role="columnheader">ЦЕНА</span>,
cell: (r) => (
<span role="cell" style={monoInk}>
{r.price}
@ -475,7 +476,7 @@ export default function SourcesView({
</div>
</div>
<div style={headerRight}>
Обновлено: <br />
{/* §M8 — литеральный «Обновлено: —» скрыт (нет реального значения). */}
Источников:{" "}
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
</div>
@ -493,9 +494,11 @@ export default function SourcesView({
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
/>
<KpiCell
label="ДИАПАЗОН P25P75"
// §M8 — «СРЕДНИЕ 50% РЫНКА» вместо статистического «ДИАПАЗОН P25P75»
// (P25P75 оставлен в скобках); «CV» → «разброс» (внутренний термин).
label="СРЕДНИЕ 50% РЫНКА (P25P75)"
value={data.marketAds.kpi.range}
unit={`млн · CV ${data.marketAds.kpi.cv}`}
unit={`млн · разброс ${data.marketAds.kpi.cv}`}
valueColor={tokens.accent}
last
/>

View file

@ -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" },

View file

@ -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(
range: hasExpected
? rangeLine(
e.expected_sold_range_low_rub,
e.expected_sold_range_high_rub,
),
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
)
: "",
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
// ("Геологическая1115 мин.Чкаловская1620"): (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 120160k fills the plot instead of flat-lining against
// a fixed 0200k 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<string, number>();
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 };

View file

@ -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;
}