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 {