diff --git a/tradein-mvp/frontend/pnpm-lock.yaml b/tradein-mvp/frontend/pnpm-lock.yaml
index 8a33c986..1eff3e29 100644
--- a/tradein-mvp/frontend/pnpm-lock.yaml
+++ b/tradein-mvp/frontend/pnpm-lock.yaml
@@ -24,6 +24,9 @@ importers:
specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
devDependencies:
+ '@eslint/eslintrc':
+ specifier: ^3.0.0
+ version: 3.3.5
'@types/node':
specifier: ^22.0.0
version: 22.19.19
diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx
index df39dcf6..36e1d1ff 100644
--- a/tradein-mvp/frontend/src/app/v2/page.tsx
+++ b/tradein-mvp/frontend/src/app/v2/page.tsx
@@ -814,6 +814,7 @@ export default function TradeInV2Page() {
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
+ hasEstimate={hasEstimate}
error={apiError}
initialValues={initialValues}
markers={markers}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
index ba54df7a..d8c15f00 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
@@ -8,6 +8,7 @@
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
+import { phYearX as histYearX } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
@@ -36,8 +37,14 @@ function parsePoints(s: string): Pt[] {
});
}
-// X positions of the year axis labels (chart scaffolding, not in fixtures).
-const histYearX = [50, 250, 460, 670, 860];
+// M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number
+// of years, using the SAME geometry the mapper uses for the polyline vertices
+// (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from
+// ./mappers (single source of truth) rather than copy-pasted, so the two never
+// drift apart. A fixed 5-slot array broke whenever the real series had ≠5
+// years: the extra labels fell back to an undefined x → x=0, so a late year
+// (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits
+// centred under its own vertex.
// ---- sell-time tier tints --------------------------------------------------
// One-off decorative tints per variant (design lines 553-556). The soft border
@@ -92,12 +99,39 @@ function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
return true;
}
+// M6 — «Срок продажи в зависимости от цены» is not a real trend when built on
+// thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн
+// median) off just 3–6 analogs, i.e. noise read as a signal. We therefore GATE
+// the chart honestly instead of faking monotonicity:
+// • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust);
+// • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced
+// by a «мало данных» note (nothing meaningful left to compare).
+const SELLTIME_MIN_N = 5;
+const SELLTIME_MIN_TILES = 3;
+
+/** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */
+function sellTimeTierN(tier: SellTimeTier): number {
+ const n = Number.parseInt(tier.count, 10);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
+function sellTimeTierValid(tier: SellTimeTier): boolean {
+ return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
+}
+
export default function AnalyticsView({
data = ANALYTICS_FIXTURE,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
+ // M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs).
+ const sellTimeTiles = useMemo(
+ () => data.sellTime.filter(sellTimeTierValid),
+ [data.sellTime],
+ );
+
// Price-history series + hover targets, derived from the data prop. Яндекс
// only draws dots where its series diverges from Avito (последние 3 года).
const avitoPts = useMemo(
@@ -215,56 +249,73 @@ export default function AnalyticsView({
>
{data.sellTimeNote}
-
{pdfHref ? (
- {pdfBtnInner}
+ {pdfBtnInner(pdfFilled)}
) : (
)}
{hasEstimate && (
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
index 39d3949d..e5bdd69b 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
@@ -20,14 +20,13 @@ interface ObjectSummaryProps {
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
// is built in mappers.mapSummary as "count · metric"; the metric is different
-// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median
-// (e.median_price_rub is area-normalised to the subject; these are raw medians
-// of the analog/deal listing prices), so users couldn't tell what they were.
-// This caption labels each ·-separated token token-for-token. Keyed by label
-// content so it matches both the mapper labels ("Продажи рядом") and the older
-// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
+// per row. This caption labels each ·-separated token token-for-token. Keyed by
+// label content so it matches both the mapper labels ("Продажи рядом") and the
+// older fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
// Продажи рядом : houseSold · kpi.median_exposure_days
-// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub))
+// Аналоги : e.n_analogs · e.median_price_rub (H3 — SAME field as the
+// 02-РЕЗУЛЬТАТ hero «медиана объявлений», not the raw analog
+// listing-price median which read far higher over the same set)
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
function rowHint(label: string): string {
@@ -356,7 +355,7 @@ export function ObjectSummary({
lineHeight: 1.7,
}}
>
- CV
+ Разброс цен
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
index e25e04eb..767cabd3 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
@@ -313,6 +313,10 @@ const styles = `
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
.pp-eval-btn:active{transform:translateY(1px)}
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
+.pp-eval-btn-secondary{background:transparent;transition:all .18s}
+.pp-eval-btn-secondary:hover{background:rgba(46,139,255,.08)}
+.pp-eval-btn-secondary:active{transform:translateY(1px)}
+.pp-eval-btn-secondary:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`;
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
@@ -457,6 +461,9 @@ interface ParamsPanelProps {
onSubmit?: (input: TradeInEstimateInput) => void;
/** Disables the button + shows a pending label while the estimate is running. */
isPending?: boolean;
+ /** M4 — once a result is on screen the primary CTA moves to «СКАЧАТЬ PDF-ОТЧЁТ»
+ * (HeroBar), so «ОЦЕНИТЬ КВАРТИРУ» (a re-run) demotes to a secondary outline. */
+ hasEstimate?: boolean;
/** Server-side error to surface inline (validation errors are handled locally). */
error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
@@ -499,10 +506,14 @@ function initRadiusLabel(radiusM: number | null | undefined): string {
export default function ParamsPanel({
onSubmit,
isPending = false,
+ hasEstimate = false,
error = null,
initialValues,
markers = [],
}: ParamsPanelProps) {
+ // M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
+ // (HeroBar); this button (a re-run) demotes to a secondary outline.
+ const secondaryCta = hasEstimate;
const [openDd, setOpenDd] = useState(null);
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
const radiusListId = useId();
@@ -1569,10 +1580,10 @@ export default function ParamsPanel({
- {/* button */}
+ {/* button — filled/primary before a result, outline/secondary after (M4). */}