diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 09fc21fc..3ea09a09 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -403,7 +403,10 @@ export default function TradeInV2Page() { useEffect(() => { const compute = () => setArtboardScale( - Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024), + Math.max( + 0.88, + Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024), + ), ); compute(); window.addEventListener("resize", compute); @@ -469,6 +472,11 @@ export default function TradeInV2Page() { estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0); const apiError = mutation.error?.message ?? null; + // M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line, + // «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate. + // Folds in `mounted` so SSR and the first client render agree (false → hidden) + // — no hydration drift, same reason the PDF control is gated behind `mounted`. + const hasEstimate = mounted && estimate != null && !insufficient; // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute // geometry). ────────────────────────────────────────────────────────── @@ -716,6 +724,7 @@ export default function TradeInV2Page() { data={{ report, object: objectInfo }} estimateId={mounted ? currentEstimateId : null} onOpenInfo={() => setDrawerOpen(true)} + hasEstimate={hasEstimate} />
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 b6255a2a..ba54df7a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -78,6 +78,20 @@ const cardStyle = { padding: "16px 18px", } as const; +// L1 — a "обычно X–Y дн" band is only honest with ≥2 lots AND a non-degenerate +// span. From a single analog (n<2) or when min===max ("237–237") the band is a +// fake range, so we suppress it and keep just the point estimate (tier.days) + +// the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the +// parsed range endpoints, so either signal alone drops the band. +function sellTimeRangeMeaningful(tier: SellTimeTier): boolean { + if (!tier.range || tier.range === "—") return false; + const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1 + if (Number.isFinite(n) && n < 2) return false; + const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18–180" → [18,180] + if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false; + return true; +} + export default function AnalyticsView({ data = ANALYTICS_FIXTURE, onNavigate, @@ -107,8 +121,29 @@ export default function AnalyticsView({ [avitoPts, yandexDots], ); + // L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any + // series that drew no points so the subtitle matches the rendered legend (the + // swatches below are already gated on *Pts.length). If the note format changes + // the replace is a graceful no-op. + const activeSeriesNames = [ + avitoPts.length > 0 ? "Avito" : null, + yandexPts.length > 0 ? "Яндекс" : null, + ].filter((s): s is string => s != null); + const historyNote = + activeSeriesNames.length > 0 + ? data.priceHistory.note.replace( + /Avito \+ Яндекс/, + activeSeriesNames.join(" + "), + ) + : data.priceHistory.note; + return ( -
+
{/* header */}
{kpi.label} @@ -216,8 +253,12 @@ export default function AnalyticsView({
- {tier.range} -
+ {sellTimeRangeMeaningful(tier) ? ( + <> + {tier.range} +
+ + ) : null} {tier.count}
@@ -240,7 +281,7 @@ export default function AnalyticsView({ История цен в этом доме
- {data.priceHistory.note} + {historyNote}
@@ -280,7 +321,10 @@ export default function AnalyticsView({ viewBox="0 0 900 220" style={{ width: "100%", height: 200, marginTop: 12 }} preserveAspectRatio="none" + role="img" + aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс" > + История цен в этом доме — медиана ₽/м² по годам @@ -454,7 +498,10 @@ export default function AnalyticsView({ + Цена × срок продажи diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx index 9c501fcb..3c564e81 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx @@ -8,9 +8,38 @@ import type { CacheData } from "./mappers"; const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows }; +/** + * M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from + * mapCache (CacheRow.time, produced by fmtCacheTime), one of: + * • same-day → "HH:MM:SS" (e.g. "23:20:51") + * • yesterday → "вчера HH:MM" + * • older → "DD.MM.YYYY" + * The raw column mixed second-precision clock times with bare dates, so a lone + * "23:20:51" was visually indistinguishable from a date. We normalize every bare + * clock time to minute precision (HH:MM) so same-day rows read uniformly with the + * "вчера HH:MM" rows, while date rows pass through unchanged. The ISO timestamp is + * already discarded upstream (mapper not editable here), so a same-day cell stays + * a time by the column's convention: a clock value ⇒ today, a date ⇒ older. + * Missing / unparseable → «—». Pure + idempotent on its own output. + */ +function normTime(raw: string | null | undefined): string { + const s = (raw ?? "").trim(); + if (!s || s === "—") return "—"; + // Bare clock time "HH:MM:SS" | "HH:MM" → minute precision (drop seconds). + const clock = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/); + if (clock) return `${clock[1].padStart(2, "0")}:${clock[2]}`; + // "вчера HH:MM:SS" → "вчера HH:MM"; dates ("DD.MM.YYYY") have no HH:MM:SS run + // and pass through unchanged. + return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1"); +} + export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) { const showSrc = data.rows.some((r) => r.src && r.src !== "—"); const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr"; + // M7 — header cells, in column order, so every data column has a header. + const headers = showSrc + ? ["АДРЕС", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"] + : ["АДРЕС", "ВРЕМЯ", "СТАТУС"]; return (
{/* 3 KPI cards */} @@ -35,7 +64,8 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) { style={{ fontSize: 9.5, letterSpacing: 1.5, - color: tokens.muted2, + // C1 — key caption (labels the headline KPI value) → body2. + color: tokens.body2, }} > {kpi.label} @@ -84,70 +114,82 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) { > Последние оценки
-
- АДРЕС - ВРЕМЯ - {showSrc && ИСТОЧНИКОВ} - СТАТУС -
- {data.rows.map((r, i) => ( + {/* M7 — real table semantics via ARIA roles over the existing grid. */} +
- {r.addr} - - {r.time} - - {showSrc && ( - - {r.src} + {headers.map((h) => ( + + {h} - )} - + {data.rows.map((r, i) => ( +
+ + {r.addr} + + {normTime(r.time)} + + {showSrc && ( + + {r.src} + + )} + - {r.status} - -
- ))} + > + + {r.status} + +
+ ))} +
); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index cfb9c908..cf79a55e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -9,9 +9,12 @@ import type { Report } from "./types"; interface FooterProps { data?: Report; + // M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate + // (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it. + hasEstimate?: boolean; } -export function Footer({ data = report }: FooterProps) { +export function Footer({ data = report, hasEstimate }: FooterProps) { return (
ДАТА {data.date} - - ДЕЙСТВИТЕЛЕН ДО{" "} - {data.validUntil} - + {hasEstimate !== false && ( + + ДЕЙСТВИТЕЛЕН ДО{" "} + {data.validUntil} + + )}
МЕРА diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index 4453c25e..604c2d17 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -40,6 +40,10 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null { interface HeroBarProps { data?: HeroBarData; estimateId?: string | null; + // M4: when false (no real, sufficient estimate yet) the estimate-dependent + // meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» — + // are hidden, mirroring how the PDF control already gates on estimate presence. + hasEstimate: boolean; onOpenInfo: () => void; } @@ -63,6 +67,7 @@ const pdfBtnStyle: CSSProperties = { export default function HeroBar({ data = HERO_FIXTURE, estimateId, + hasEstimate, onOpenInfo, }: HeroBarProps) { const pdfHref = pdfDownloadHref(estimateId); @@ -159,32 +164,34 @@ export default function HeroBar({ {data.report.date}
-
+ {hasEstimate && (
- ДЕЙСТВИТЕЛЕН ДО +
+ ДЕЙСТВИТЕЛЕН ДО +
+
+ {data.report.validUntil} +
-
- {data.report.validUntil} -
-
+ )}
@@ -208,30 +215,32 @@ export default function HeroBar({ {pdfBtnInner} )} - + {hasEstimate && ( + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx index cd0faa0f..2c590e3c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx @@ -69,52 +69,66 @@ export default function HistoryView({ -
- ЛОТ - ЦЕНА НАЧ. → ПОСЛ. - ДАТА - ЭКСПОЗИЦИЯ -
- - {data.houseSales.map((row, i) => ( +
- {row.lot} - - {row.priceFrom} →{" "} - {row.priceTo} - - - {row.date} - - - {row.exposure} - + ЛОТ + ЦЕНА НАЧ. → ПОСЛ. + ДАТА + ЭКСПОЗИЦИЯ
- ))} + + {data.houseSales.map((row, i) => ( +
+ {row.lot} + + {row.priceFrom} →{" "} + {row.priceTo} + + + {row.date} + + + {row.exposure} + +
+ ))} +
{/* ── ДКП-сделки на улице ──────────────────────────────────── */} @@ -144,7 +158,8 @@ export default function HistoryView({ style={{ fontSize: "8.5px", letterSpacing: "2px", - color: tokens.muted2, + // C1: primary section eyebrow → body2 (ObjectSummary pattern). + color: tokens.body2, marginBottom: "5px", }} > @@ -205,7 +220,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > СДЕЛОК ЗА 24 МЕС @@ -232,7 +248,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > МЕДИАНА @@ -257,7 +274,8 @@ export default function HistoryView({ style={{ fontSize: "9px", letterSpacing: "1.5px", - color: tokens.muted2, + // C1: primary KPI label → body2 (above the muted value/unit). + color: tokens.body2, }} > ДИАПАЗОН @@ -280,86 +298,104 @@ export default function HistoryView({ {/* Table header */} -
- АДРЕС - ПЛОЩАДЬ - ЦЕНА СДЕЛКИ - ₽/М² - ДАТА - {showAsk && ОБЪЯВЛЕНИЕ ДО СДЕЛКИ} -
- - {data.dkpRows.map((r, i) => ( +
- {r.addr ?? "—"} - - {r.area} - - - {r.price} - - - {r.ppm} - - АДРЕС + ПЛОЩАДЬ + ЦЕНА СДЕЛКИ + ₽/М² + ДАТА + {showAsk && ОБЪЯВЛЕНИЕ ДО СДЕЛКИ} +
+ + {data.dkpRows.map((r, i) => ( +
- {r.date ?? "—"} - - {showAsk && ( + + {r.addr ?? "—"} + + {r.area} + + + {r.price} + + + {r.ppm} + + + {r.date ?? "—"} + + {showAsk && ( - {r.source ?? "—"} + + {r.source ?? "—"} + + {r.ask} + {r.delta} - {r.ask} - {r.delta} - - )} -
- ))} + )} +
+ ))} +
(null); + const lastFocused = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Focus management + Escape/Tab handling while open. Unlike SectionOverlay + // (mounted only when open) this drawer stays mounted for the slide animation, + // so the effect is keyed on `open`: trap focus on open, restore it on close. useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + const dialog = dialogRef.current; + // Remember the trigger, then move focus into the dialog. + lastFocused.current = document.activeElement; + dialog?.focus(); + + function getFocusable(): HTMLElement[] { + if (!dialog) return []; + const nodes = dialog.querySelectorAll( + 'button, [href], input, select, [tabindex]:not([tabindex="-1"])', + ); + return Array.from(nodes).filter((el) => !el.hasAttribute("disabled")); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + // Stop the event so a stacked overlay below can't also consume it and so + // the browser runs no default action; then close this (topmost) drawer. + e.preventDefault(); + e.stopPropagation(); + onCloseRef.current(); + return; + } + if (e.key !== "Tab" || !dialog) return; + + const focusable = getFocusable(); + if (focusable.length === 0) { + // Nothing tabbable inside — keep focus pinned on the dialog itself. + e.preventDefault(); + dialog.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeEl = document.activeElement; + const inside = dialog.contains(activeEl); + + if (e.shiftKey) { + if (activeEl === first || activeEl === dialog || !inside) { + e.preventDefault(); + last.focus(); + } + } else if (activeEl === last || !inside) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + // Return focus to whatever opened the drawer. + (lastFocused.current as HTMLElement | null)?.focus?.(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, onClose]); + // `onClose` is read via onCloseRef; refs are stable → only `open` is reactive. + }, [open]); return ( <> @@ -37,6 +99,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { width: 32px; height: 32px; flex: 0 0 auto; + padding: 0; border: 1px solid ${tokens.line}; border-radius: 6px; display: flex; @@ -44,7 +107,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { justify-content: center; cursor: pointer; color: ${tokens.muted}; + font-family: inherit; font-size: 16px; + line-height: 1; background: ${tokens.surface.w60}; transition: all .15s; } @@ -69,13 +134,18 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { }} /> - {/* drawer */} + {/* drawer — modal dialog (role / focus-trap / inert mirror SectionOverlay) */}
МЕТОДИКА
-
+
+
{/* divider under header */} @@ -181,7 +257,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { style={{ fontSize: 9, letterSpacing: "2px", - color: tokens.muted2, + color: tokens.body2, marginTop: 22, marginBottom: 12, }} @@ -195,7 +271,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { color: tokens.body2, }} > - Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки + Агрегируем объявления (Циан, Я.Недвижимость, Авито, N1.ru) и сделки Росреестра по сопоставимым квартирам. Медиана{" "} ₽/м² → 3 оценки:{" "} 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 55ee2945..90489be3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -18,6 +18,26 @@ interface ObjectSummaryProps { onNavigate: (i: number) => void; } +// 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: +// Продажи рядом : houseSold · kpi.median_exposure_days +// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub)) +// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals) +// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct +function rowHint(label: string): string { + if (label.startsWith("Продажи")) return "кол-во · медиана экспозиции"; + if (label === "Аналоги") return "кол-во · медиана объявлений"; + if (label === "Сделки") return "кол-во · медиана сделок"; + if (label === "Аналитика") return "экспозиция · торг"; + return ""; +} + export function ObjectSummary({ data = OBJECT_SUMMARY_FIXTURE, onNavigate, @@ -109,7 +129,7 @@ export function ObjectSummary({ style={{ fontSize: 9, letterSpacing: 1.2, - color: tokens.muted3, + color: tokens.body2, marginBottom: 13, }} > @@ -158,7 +178,9 @@ export function ObjectSummary({ {/* Per-section totals (clickable) */}
- {data.summary.rows.map((row, i) => ( + {data.summary.rows + .filter((row) => row.value !== "—") + .map((row, i, visible) => (