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 specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
devDependencies: devDependencies:
'@eslint/eslintrc':
specifier: ^3.0.0
version: 3.3.5
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.19 version: 22.19.19

View file

@ -814,6 +814,7 @@ export default function TradeInV2Page() {
key={estimate?.estimate_id ?? "new"} key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isPending={mutation.isPending} isPending={mutation.isPending}
hasEstimate={hasEstimate}
error={apiError} error={apiError}
initialValues={initialValues} initialValues={initialValues}
markers={markers} markers={markers}

View file

@ -8,6 +8,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures"; import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX } from "./mappers";
import type { Analytics, SellTimeTier } from "./types"; import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps { interface AnalyticsViewProps {
@ -36,8 +37,14 @@ function parsePoints(s: string): Pt[] {
}); });
} }
// X positions of the year axis labels (chart scaffolding, not in fixtures). // M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number
const histYearX = [50, 250, 460, 670, 860]; // 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 -------------------------------------------------- // ---- sell-time tier tints --------------------------------------------------
// One-off decorative tints per variant (design lines 553-556). The soft border // One-off decorative tints per variant (design lines 553-556). The soft border
@ -92,12 +99,39 @@ function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
return true; 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({ export default function AnalyticsView({
data = ANALYTICS_FIXTURE, data = ANALYTICS_FIXTURE,
onNavigate, onNavigate,
}: AnalyticsViewProps) { }: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false); 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. Яндекс // Price-history series + hover targets, derived from the data prop. Яндекс
// only draws dots where its series diverges from Avito (последние 3 года). // only draws dots where its series diverges from Avito (последние 3 года).
const avitoPts = useMemo( const avitoPts = useMemo(
@ -215,56 +249,73 @@ export default function AnalyticsView({
> >
{data.sellTimeNote} {data.sellTimeNote}
</div> </div>
<div {sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
style={{ <div
display: "grid", style={{
gridTemplateColumns: "repeat(4,1fr)", display: "grid",
gap: 12, gridTemplateColumns: `repeat(${sellTimeTiles.length},1fr)`,
}} gap: 12,
> }}
{data.sellTime.map((tier, i) => { >
const v = tierStyles[tier.variant]; {sellTimeTiles.map((tier, i) => {
return ( const v = tierStyles[tier.variant];
<div return (
key={i}
style={{
background: v.bg,
border: v.border,
borderRadius: 7,
padding: "13px 15px",
}}
>
<div
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
>
{tier.tier}
</div>
<div <div
key={i}
style={{ style={{
fontFamily: tokens.font.mono, background: v.bg,
fontSize: 26, border: v.border,
fontWeight: 300, borderRadius: 7,
color: tokens.ink2, padding: "13px 15px",
margin: "6px 0 4px",
}} }}
> >
{tier.days} <div
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
>
{tier.tier}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 26,
fontWeight: 300,
color: tokens.ink2,
margin: "6px 0 4px",
}}
>
{tier.days}
</div>
<div
style={{
fontSize: 9.5,
color: tokens.hint,
lineHeight: 1.5,
}}
>
{sellTimeRangeMeaningful(tier) ? (
<>
{tier.range}
<br />
</>
) : null}
{tier.count}
</div>
</div> </div>
<div );
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }} })}
> </div>
{sellTimeRangeMeaningful(tier) ? ( ) : (
<> <div
{tier.range} style={{
<br /> fontSize: 11,
</> color: tokens.muted2,
) : null} lineHeight: 1.5,
{tier.count} padding: "6px 0 2px",
</div> }}
</div> >
); Мало данных для оценки чувствительности срока продажи к цене.
})} </div>
</div> )}
</div> </div>
{/* история цен */} {/* история цен */}
@ -339,9 +390,18 @@ export default function AnalyticsView({
</text> </text>
))} ))}
</g> </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) => ( {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} {yr}
</text> </text>
))} ))}

View file

@ -64,6 +64,17 @@ const pdfBtnStyle: CSSProperties = {
transition: "all .15s", 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({ export default function HeroBar({
data = HERO_FIXTURE, data = HERO_FIXTURE,
estimateId, estimateId,
@ -71,22 +82,29 @@ export default function HeroBar({
onOpenInfo, onOpenInfo,
}: HeroBarProps) { }: HeroBarProps) {
const pdfHref = pdfDownloadHref(estimateId); 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 // Hide the building photo if the asset 404s/400s so the photoBg fill shows
// instead of a broken-image icon. // instead of a broken-image icon.
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const pdfBtnInner = ( const pdfBtnInner = (filled: boolean) => {
<> const frame = filled ? "#fff" : "#2e8bff";
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true"> const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" /> return (
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" /> <>
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" /> <svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" /> <path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
</svg> <path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}> <line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
СКАЧАТЬ PDF-ОТЧЁТ <line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
</span> </svg>
</> <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
); СКАЧАТЬ PDF-ОТЧЁТ
</span>
</>
);
};
return ( return (
<div <div
@ -100,6 +118,9 @@ export default function HeroBar({
<style>{` <style>{`
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); } .hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
.hero-pdf-btn:active { transform: translateY(1px); } .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-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); } .hero-coef-row:hover { background: rgba(46,139,255,.09); }
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } } @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 }}> <div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
{pdfHref ? ( {pdfHref ? (
<a <a
className="hero-pdf-btn" className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
href={pdfHref} href={pdfHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={pdfBtnStyle} style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
> >
{pdfBtnInner} {pdfBtnInner(pdfFilled)}
</a> </a>
) : ( ) : (
<button <button
@ -212,7 +233,7 @@ export default function HeroBar({
title="Отчёт станет доступен после расчёта оценки" title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }} style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
> >
{pdfBtnInner} {pdfBtnInner(false)}
</button> </button>
)} )}
{hasEstimate && ( {hasEstimate && (

View file

@ -20,14 +20,13 @@ interface ObjectSummaryProps {
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value // 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 // is built in mappers.mapSummary as "count · metric"; the metric is different
// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median // per row. This caption labels each ·-separated token token-for-token. Keyed by
// (e.median_price_rub is area-normalised to the subject; these are raw medians // label content so it matches both the mapper labels ("Продажи рядом") and the
// of the analog/deal listing prices), so users couldn't tell what they were. // older fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
// 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 // Продажи рядом : 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) // Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct // Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
function rowHint(label: string): string { function rowHint(label: string): string {
@ -356,7 +355,7 @@ export function ObjectSummary({
lineHeight: 1.7, lineHeight: 1.7,
}} }}
> >
<span>CV</span> <span>Разброс цен</span>
<span <span
style={{ fontFamily: tokens.font.mono, color: tokens.accent }} 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:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
.pp-eval-btn:active{transform:translateY(1px)} .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: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 // Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
@ -457,6 +461,9 @@ interface ParamsPanelProps {
onSubmit?: (input: TradeInEstimateInput) => void; onSubmit?: (input: TradeInEstimateInput) => void;
/** Disables the button + shows a pending label while the estimate is running. */ /** Disables the button + shows a pending label while the estimate is running. */
isPending?: boolean; 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). */ /** Server-side error to surface inline (validation errors are handled locally). */
error?: string | null; error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ /** 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({ export default function ParamsPanel({
onSubmit, onSubmit,
isPending = false, isPending = false,
hasEstimate = false,
error = null, error = null,
initialValues, initialValues,
markers = [], markers = [],
}: ParamsPanelProps) { }: 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); const [openDd, setOpenDd] = useState<DdKey>(null);
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id). // РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId(); const radiusListId = useId();
@ -1569,10 +1580,10 @@ export default function ParamsPanel({
</div> </div>
</div> </div>
{/* button */} {/* button — filled/primary before a result, outline/secondary after (M4). */}
<button <button
type="button" type="button"
className="pp-eval-btn" className={secondaryCta ? "pp-eval-btn-secondary" : "pp-eval-btn"}
onClick={handleSubmit} onClick={handleSubmit}
disabled={isPending} disabled={isPending}
style={{ style={{
@ -1588,7 +1599,7 @@ export default function ParamsPanel({
cursor: isPending ? "wait" : "pointer", cursor: isPending ? "wait" : "pointer",
opacity: isPending ? 0.6 : 1, opacity: isPending ? 0.6 : 1,
fontFamily: tokens.font.sans, fontFamily: tokens.font.sans,
color: tokens.onAccent, color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
@ -1604,9 +1615,11 @@ export default function ParamsPanel({
justifyContent: "center", justifyContent: "center",
width: 26, width: 26,
height: 26, height: 26,
border: "1px solid rgba(255,255,255,.7)", border: `1px solid ${
secondaryCta ? tokens.accent : "rgba(255,255,255,.7)"
}`,
borderRadius: "50%", borderRadius: "50%",
color: tokens.onAccent, color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
}} }}
> >

View file

@ -14,7 +14,9 @@ import type { ResultPanelData } from "./mappers";
const { const {
accent, accent,
accentDeep,
ink, ink,
body,
body2, body2,
muted, muted,
muted2, muted2,
@ -158,7 +160,7 @@ export default function ResultPanel({
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b> ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
</span> </span>
<span> <span>
CV{" "} РАЗБРОС ЦЕН{" "}
<b style={{ color: accent, fontFamily: font.mono }}> <b style={{ color: accent, fontFamily: font.mono }}>
{data.meta.cv} {data.meta.cv}
</b> </b>
@ -176,17 +178,24 @@ export default function ResultPanel({
}} }}
> >
{data.cards.map((card, ci) => { {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 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline) // ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~43% → demoted) // 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 // value so the user can't confuse it with the registry figure; the
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО" // registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
// tile. All three values stay (honesty); wiring (mapResultPanel) is // tile. Value scale reads expected(40) ≫ asking(30) > ДКП(28).
// untouched — emphasis is presentation only. //
const isHeadline = ci === 1; // 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 isRegistry = ci === 2;
const headlineIndex = expectedEmpty ? 0 : 1;
const isHeadline = !isEmptyCard && ci === headlineIndex;
const isAsking = ci === 0;
return ( return (
<div <div
key={ci} key={ci}
@ -225,7 +234,12 @@ export default function ResultPanel({
style={{ style={{
fontSize: 9.5, fontSize: 9.5,
letterSpacing: 1.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, fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6, lineHeight: 1.6,
}} }}
@ -241,74 +255,133 @@ export default function ResultPanel({
marginTop: 3, marginTop: 3,
}} }}
> >
СПРАВОЧНО {/* §M8 — расшифровка «ДКП» один раз на секцию. */}
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
</div> </div>
)} )}
<div {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» just an
style={{ honest note pointing at the recommended asking price. */}
marginTop: 14, {isEmptyCard ? (
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1,
color: isHeadline ? accent : isRegistry ? muted : undefined,
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
{card.note && (
<div <div
style={{ style={{
fontSize: 9, marginTop: 12,
color: muted2, fontSize: 11,
marginTop: 4, lineHeight: 1.5,
lineHeight: 1.4, color: body,
maxWidth: "82%", maxWidth: "94%",
}} }}
> >
{card.note} {card.emptyNote}
</div> </div>
) : (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
// 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,
// 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}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
)}
{card.ppm && (
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
)}
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{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 <div
style={{ style={{
display: "flex", display: "flex",
@ -363,7 +436,50 @@ export default function ResultPanel({
</button> </button>
</div> </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 <button
type="button" type="button"
className="rp-more" className="rp-more"
@ -372,87 +488,22 @@ export default function ResultPanel({
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end",
gap: 4, gap: 4,
cursor: "pointer", cursor: "pointer",
fontSize: 9, fontSize: 9,
letterSpacing: 0.3, letterSpacing: 0.3,
color: accent, color: accent,
width: "100%", whiteSpace: "nowrap",
background: "none", background: "none",
border: "none", border: "none",
padding: 0, padding: 0,
margin: 0, margin: 0,
marginTop: 16,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
> >
Подробнее <span></span> Подробнее <span></span>
</button> </button>
<div </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> </div>
); );
@ -464,9 +515,11 @@ export default function ResultPanel({
не читался как «неуверенность расчёта». */} не читался как «неуверенность расчёта». */}
<div <div
style={{ style={{
fontSize: 10, // M3 — было 10px muted-микропринт; повышено до 12px body, чтобы
// ключевая honest-оговорка о разбросе читалась, а не терялась.
fontSize: 12,
lineHeight: 1.5, lineHeight: 1.5,
color: hint, color: body,
marginTop: -4, 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 // effect (deps []) never re-runs / re-traps focus on parent re-renders even
// though page.tsx passes a fresh arrow each render. // though page.tsx passes a fresh arrow each render.
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null); const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
onCloseRef.current = 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(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
// Remember the trigger, then move focus into the dialog. // Remember the trigger, then move focus into the dialog.
@ -294,6 +303,7 @@ export default function SectionOverlay({
{/* overlay body */} {/* overlay body */}
<div <div
ref={bodyRef}
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,

View file

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

View file

@ -624,7 +624,7 @@ export const dropdownOptions: DropdownOptions = {
export const locationFactors: LocationFactors = { export const locationFactors: LocationFactors = {
coef: "0.87", coef: "0.87",
intro: intro:
"Показывает, как адрес корректирует стоимость относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", "Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" }, formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
positives: [ positives: [
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" }, { label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },

View file

@ -262,12 +262,15 @@ function coeffVar(values: number[]): number | null {
return (Math.sqrt(variance) / mean) * 100; 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 { function cvStr(values: number[]): string {
const cv = coeffVar(values); const cv = coeffVar(values);
return cv != null return cv != null
? `${cv.toLocaleString("ru-RU", { ? `${cv.toLocaleString("ru-RU", {
minimumFractionDigits: 1, maximumFractionDigits: 0,
maximumFractionDigits: 1,
})}%` })}%`
: "—"; : "—";
} }
@ -857,6 +860,14 @@ export function mapResultPanel(
): ResultPanelData { ): ResultPanelData {
const dealTier = resolveDealTier(e, streetDeals); 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[] = [ const cards: ResultCard[] = [
{ {
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"], title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
@ -869,21 +880,29 @@ export function mapResultPanel(
}, },
{ {
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"], title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
value: fmtMln(e.expected_sold_price_rub), value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
unit: "млн ₽", unit: "млн ₽",
range: rangeLine( range: hasExpected
e.expected_sold_range_low_rub, ? rangeLine(
e.expected_sold_range_high_rub, 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: delta:
e.asking_to_sold_ratio != null hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100) ? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: "—", : undefined,
// §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса. // §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
// «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»). // сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
deltaLabel: "К ОБЪЯВЛ.", // обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
deltaLabel: "к цене объявления",
// H1 empty-state message (only when there is no expected-sold prediction).
emptyNote: hasExpected
? undefined
: "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
note: note:
hasExpected &&
dealTier != null && dealTier != null &&
e.expected_sold_price_rub != null && e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub) && Number.isFinite(e.expected_sold_price_rub) &&
@ -972,11 +991,18 @@ export function mapSummary(
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}` ? `${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 = const row2Value =
e.n_analogs > 0 e.n_analogs > 0
? `${e.n_analogs}${ ? `${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. * touches the comma-splitting parseAddress.
*/ */
function deglueAddr(addr: string): string { 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) ───────────────────────────────────────── // ── 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) // [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 // 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. // a fixed 0200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
const PH_X_LEFT = 58; // PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
const PH_X_RIGHT = 885; // (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_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value) const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175 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. // the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; 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; if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1); return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
} }
@ -1802,7 +1841,9 @@ export function mapSources(
kpi: { kpi: {
count: dealCount > 0 ? String(dealCount) : "—", count: dealCount > 0 ? String(dealCount) : "—",
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—", median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—", // §M8 — единая формулировка дельт: всё считается «относительно цен
// объявлений» (deltaPct = медиана сделок / медиана объявлений 1).
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
range: mlnRangeSp(dealLo, dealHi), range: mlnRangeSp(dealLo, dealHi),
}, },
}; };
@ -1887,8 +1928,8 @@ function cacheStatus(iso: string | null | undefined): {
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на * 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
* фронте из per-user /history (null пустая таблица + KPI с "—"). * фронте из per-user /history (null пустая таблица + KPI с "—").
* rows addr/время/статус по каждой строке (src всегда "—", TODO BE-1). * 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 { export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const list = history ?? []; const list = history ?? [];
@ -1896,7 +1937,10 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const rows: CacheRow[] = list.map((h) => { const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at); const { status, statusColor } = cacheStatus(h.created_at);
return { 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), time: fmtCacheTime(h.created_at),
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
status, status,
@ -1916,17 +1960,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
? prices.reduce((a, b) => a + b, 0) / prices.length ? prices.reduce((a, b) => a + b, 0) / prices.length
: null; : null;
// KPI 3 — доля переоценок: (всего адресов уникальных) / всего адресов. // §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
const seen = new Map<string, number>(); // meaning for the end user, so it is dropped from this витрина. Only the two
for (const h of list) { // user-relevant KPIs remain.
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;
const kpis: CacheKpi[] = [ const kpis: CacheKpi[] = [
{ {
label: "ВСЕГО ОЦЕНОК", label: "ВСЕГО ОЦЕНОК",
@ -1939,14 +1975,6 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
unit: avg != null ? "млн ₽" : undefined, unit: avg != null ? "млн ₽" : undefined,
sub: "по проведённым оценкам", sub: "по проведённым оценкам",
}, },
{
// fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" —
// для доли это неверно; рендерим число + unit "%" (как в дизайне).
label: "ПОВТОРНЫЕ АДРЕСА",
value: dupPct != null ? String(Math.round(dupPct)) : "—",
unit: dupPct != null ? "%" : undefined,
sub: "доля переоценок",
},
]; ];
return { rows, kpis }; return { rows, kpis };

View file

@ -38,12 +38,16 @@ export interface ResultCard {
ppm: string; ppm: string;
/** Mini-histogram column heights in % (cards 1 & 3). */ /** Mini-histogram column heights in % (cards 1 & 3). */
bars?: number[]; 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; delta?: string;
/** Donut caption, e.g. "К РЫНКУ" (card 2). */ /** Delta pill caption, e.g. "к цене объявления" (card 2). */
deltaLabel?: string; deltaLabel?: string;
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */ /** Reconciliation note under the value (card 2: expected price vs ДКП median). */
note?: string; 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 index opened by the card's "Подробнее →" link. */
nav: number; nav: number;
} }