From b2bd82750708f987ef0f0a0d8bc8258cfcdb66da Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 17:32:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/v2):=20polish=20=D0=90=D0=9D=D0=90?= =?UTF-8?q?=D0=9B=D0=98=D0=A2=D0=98=D0=9A=D0=90=20=D0=94=D0=9E=D0=9C=D0=90?= =?UTF-8?q?=20=E2=80=94=20=D1=80=D0=B0=D1=81=D1=82=D1=8F=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20scatter,=20=D0=BA=D0=BB=D1=8D=D0=BC=D0=BF=20=D0=BE?= =?UTF-8?q?=D1=81=D0=B8=20=D0=BA=200,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BB=D0=B5=D0=B3=D0=B5=D0=BD=D0=B4=D1=8B/?= =?UTF-8?q?=D0=B1=D0=B0=D0=BA=D0=B5=D1=82=D1=8B,=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=84=D0=B5=D0=B9=D0=BA-=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay 06 "АНАЛИТИКА ДОМА" had several display/chart defects vs the design: - scatter "Цена × срок продажи" letterboxed into a narrow column (missing preserveAspectRatio="none", unlike the price-history svg). - Y-domain wasn't clamped to 0 in buildScatterDetail AND buildScatterMini (same root-cause bug in both), so a thin/high price cluster could show a negative "−1M" axis tick. - "сделки" legend/series rendered even with 0 deal points (Rosreestr ДКП always have days_on_market=null → never plotted) — now gated like the avito/yandex conditional legend. - the "линия тренда" was a literal hardcoded x1/y1/x2/y2 line, entirely data-independent — removed (a real regression is a separate feature). - bargainColor sign was inverted: it greened a price RISE, but the displayed bargainSigned value negates the raw pct, and the fixture (kpis[1], "−3.0%") greens the price-DROP case actually shown on screen. - sell-time buckets ignored the backend insufficient_data flag; threaded it through SellTimeBucket -> SellTimeTier -> sellTimeTierValid so a bucket the backend calls too thin renders as "—", not a confident value. - subject scatter point silently used a hardcoded 90-day fallback with no marker when est_days_on_market was null; now flagged (subjectApprox) and rendered as a dashed/lower-opacity estimate instead of passing off as measured data. - sold_rate_pct lacked a Number.isFinite guard like the other formatters. - PH_GRID_Y gridlines were unevenly spaced (last gap 40px vs 45px elsewhere); evened out and exported as the single source of truth so AnalyticsView's background gridlines can no longer drift from the yTick label positions (they previously duplicated the same y's as a second hardcoded literal). --- .../components/trade-in/v2/AnalyticsView.tsx | 108 ++++++++++-------- .../src/components/trade-in/v2/fixtures.ts | 6 +- .../src/components/trade-in/v2/mappers.ts | 45 +++++++- .../src/components/trade-in/v2/types.ts | 4 + tradein-mvp/frontend/src/types/trade-in.ts | 1 + 5 files changed, 107 insertions(+), 57 deletions(-) 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 d8c15f00..6d972aa4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -8,7 +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 { phYearX as histYearX, PH_GRID_Y } from "./mappers"; import type { Analytics, SellTimeTier } from "./types"; interface AnalyticsViewProps { @@ -115,9 +115,18 @@ function sellTimeTierN(tier: SellTimeTier): number { return Number.isFinite(n) ? n : 0; } -/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */ +/** + * A tier is trustworthy only with a real point estimate, ≥ SELLTIME_MIN_N lots, + * AND the backend not having flagged it as insufficient (V3) — a bucket the + * backend itself calls too thin is dropped like any other untrustworthy tile, + * never shown as a confident value. + */ function sellTimeTierValid(tier: SellTimeTier): boolean { - return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N; + return ( + !tier.insufficientData && + tier.days !== "—" && + sellTimeTierN(tier) >= SELLTIME_MIN_N + ); } export default function AnalyticsView({ @@ -377,11 +386,9 @@ export default function AnalyticsView({ > История цен в этом доме — медиана ₽/м² по годам - - - - - + {PH_GRID_Y.map((y) => ( + + ))} {data.priceHistory.yTicks.map((t) => ( @@ -517,24 +524,26 @@ export default function AnalyticsView({ /> аналоги - + {data.scatterDetail.deals.length > 0 ? ( - сделки - + > + + сделки + + ) : null} @@ -588,16 +598,6 @@ export default function AnalyticsView({ stroke={tokens.line3} strokeWidth={1.5} /> - {data.scatterDetail.deals.map((p, i) => ( @@ -608,21 +608,33 @@ export default function AnalyticsView({ ))} - - + + {/* V4: subjectApprox marks a fallback x (est_days_on_market unknown) — + dashed ring + title flag it as an estimate, not a measurement. */} + {data.scatterDetail.subjectApprox ? ( + + Срок продажи — оценка (~90 дн.), точных данных нет + + ) : null} + + + 0 means the asking price was REDUCED (see the + // bargainSigned doc comment below) — the common, honest "price dropped" + // case, which bargainSigned negates into the displayed "−X%". That displayed + // drop is the one the fixture (analytics.kpis[1], "−3.0%") colours success — + // NOT a raw negative pct (which would mean the price rose and bargainSigned + // would show a misleading "+X%" in green). The old `< 0` check had this + // backwards. const bargainColor = - k?.median_bargain_pct != null && k.median_bargain_pct < 0 + k?.median_bargain_pct != null && k.median_bargain_pct > 0 ? tokens.success : undefined; return [ @@ -1464,7 +1474,10 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] { }, { label: "ДОЛЯ СНЯТЫХ", - value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—", + value: + k != null && Number.isFinite(k.sold_rate_pct) + ? `${Math.round(k.sold_rate_pct)}%` + : "—", sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—", }, ]; @@ -1517,6 +1530,7 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] { : "—", count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`, variant: meta.variant, + insufficientData: b.insufficient_data === true, }; }); } @@ -1534,7 +1548,10 @@ 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 -const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom) +// Exported (not just local) so AnalyticsView's background gridlines share this +// EXACT geometry with the yTick labels below (C5) — a hardcoded copy in the view +// drifted out of sync with this array; single source of truth like phYearX/PH_X_*. +export const PH_GRID_Y = [20, 64, 108, 151, 195]; // 5 gridlines, evenly spaced (top → bottom) // Both polylines are drawn from these source keys; the Y zoom is computed over // the union of their finite medians so the two series share one axis. const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; @@ -1724,15 +1741,23 @@ function buildScatterDetail( yMax *= 1.05; } const yPad = (yMax - yMin) * 0.08; + // C3: clamp the domain floor to 0 (mirror buildPriceHistory / buildScatterMini) + // — an un-clamped yMin-yPad can go negative, surfacing a "−1M" axis tick. const domain: ScatterDomain = { xMin: DETAIL_X_DOMAIN.min, xMax: DETAIL_X_DOMAIN.max, - yMin: yMin - yPad, + yMin: Math.max(0, yMin - yPad), yMax: yMax + yPad, }; const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5); const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5); + // V4: est_days_on_market is honestly unknown for a meaningful share of + // estimates. Falling back to a silent 90 plotted the subject as if its + // exposure were a real measurement. Keep the fallback (dropping the point + // entirely would lose a real, known subjectPrice) but flag it so the view + // can mark the dot as an estimate instead of presenting it as data. + const subjectApprox = Number.isFinite(subjectPrice) && subjectDays == null; const subject = Number.isFinite(subjectPrice) ? scatterProject( [{ x: subjectDays ?? 90, y: subjectPrice }], @@ -1749,7 +1774,15 @@ function buildScatterDetail( return { label: mlnTick(price), y: py }; }); - return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS }; + return { + note, + deals, + analogs, + subject, + subjectApprox, + yTicks, + xTicks: DETAIL_X_TICKS, + }; } /** 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 c567db8c..3f3da24d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -208,6 +208,8 @@ export interface SellTimeTier { range: string; count: string; variant: "success" | "accent" | "gold" | "danger"; + /** Backend-flagged bucket too thin to trust (see SellTimeBucket.insufficient_data). */ + insufficientData?: boolean; } export interface PriceHistory { @@ -225,6 +227,8 @@ export interface ScatterDetail { deals: ScatterPoint[]; analogs: ScatterPoint[]; subject: ScatterPoint; + /** True when subject.x is a fallback (est_days_on_market unknown), not a real value. */ + subjectApprox?: boolean; yTicks: AxisTickY[]; xTicks: AxisTickX[]; } diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 6ae8fc38..8e559418 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -400,6 +400,7 @@ export interface SellTimeBucket { p25_days: number | null; p75_days: number | null; n_lots: number; + insufficient_data?: boolean; // backend flags a bucket too thin to trust } export interface SellTimeSensitivityResponse {