From bb4a6d220fbd84b900a5de1d9df8423d84aa591e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 00:12:26 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D1=83=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BA=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=83=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B6=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=BC=D0=BE=D0=B5=20=D0=BA=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=BE=20=D0=BF=D0=BE=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=20=D0=B4=D0=BE=20=D0=BD=D0=B0=D0=B6=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран проверки дорисовывал результат НИЖЕ формы и никуда не уводил: на 375 px человек после нажатия видел ту же форму, а заголовок ответа оставался за нижней кромкой — нажатие читается как «ничего не произошло». Ответ теперь получает фокус и прокрутку; анимация прокрутки спрашивается у prefers-reduced-motion, той же медиа-функции, что глушит остальную анимацию витрины. Фокус здесь не украшение: без него клавиатурный пользователь остаётся на кнопке и следующим Tab уходит в обход ответа, а живая область объявляет текст, но не перемещает точку ввода. Второе: в дропдауне девять городов, и они не равны по данным, но узнать об этом можно было только ПОСЛЕ нажатия. Замер на проде (30.08.2026, симуляция когорты самой ручки /coverage по случайным адресам активных объявлений, собственный адрес исключён): доля проверок с выборкой не ниже городского порога — ЕКБ 83 %, Верхняя Пышма 70, Серов 58, Нижний Тагил 52, Первоуральск 50, Каменск-Уральский 45, Среднеуральск 39, Берёзовский 38, Ревда 16 (по 120 адресов, Среднеуральск — 56, столько их там есть). Величина и её источник лежат в landing-facts.ts, формулировка — в coverage-copy.ts, в компонент не вписано ни одного числа. Города из списка НЕ убраны: систематического отказа нет ни в одном (пустая когорта у худшего — 11 случаев из 100), разница между ними количественная, и её честнее назвать числом, чем снятием опции. Счёт по listings.city, дающий ноль по трём городам-спутникам, здесь не годится — колонка хранит город свипа скрейпера, а не геокод объявления (разбор над _CITY_CENTROIDS_DEG в trade_in.py). Тест требует замера на каждый город из OBLAST_CITIES — добавить город в дропдаун, не измерив его, теперь нельзя. --- .../__tests__/coverage-copy.test.ts | 42 +++++++++++++- .../_components/estimate/EstimateFlow.tsx | 50 +++++++++++++++- .../src/app/mera-public/coverage-copy.ts | 32 +++++++++++ .../src/app/mera-public/landing-facts.ts | 57 +++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 6e9bd162..3d03e999 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { describeCoverage } from "../coverage-copy"; +import { OBLAST_CITIES } from "@/lib/city-registry"; + +import { describeCityExpectation, describeCoverage } from "../coverage-copy"; +import { CITY_COVERAGE } from "../landing-facts"; import type { CoverageProbe } from "../public-api"; /** @@ -111,3 +114,40 @@ describe("describeCoverage", () => { } }); }); + +/** + * Ожидаемое качество по городу. Проверяется не вёрстка, а два обещания: город + * из дропдауна не может остаться без замера, и величина названа тем, чем она + * измерена (доля проверок, а не «объём базы» и не «точность»). + */ +describe("describeCityExpectation", () => { + it("у каждого предлагаемого города есть замер — и лишних замеров нет", () => { + const offered = OBLAST_CITIES.map((c) => c.label).sort(); + const measured = CITY_COVERAGE.map((m) => m.city).sort(); + expect(measured).toEqual(offered); + }); + + it("у величины есть размер выборки и источник — без них она на витрину не выходит", () => { + for (const measure of CITY_COVERAGE) { + expect(measure.sampleN, `${measure.city}: выборка не указана`).toBeGreaterThan(0); + const said = describeCityExpectation(measure.city); + expect(said, `${measure.city}: нет текста`).not.toBeNull(); + expect(said?.source).toContain(String(measure.sampleN)); + expect(said?.source.length).toBeGreaterThan(40); + expect(said?.text).toContain(String(measure.confidentPct)); + } + }); + + it("называет ту величину, которая измерена, — долю проверок, а не точность", () => { + const said = describeCityExpectation("Ревда"); + expect(said?.text).toContain("проверках"); + expect(said?.text).not.toMatch(/точност/i); + // Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст + // утверждает больше, чем измерено. + expect(said?.text).toContain("84"); + }); + + it("неизвестный город не выдумывает величину", () => { + expect(describeCityExpectation("Москва")).toBeNull(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx index d34a8918..66c35579 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -26,7 +26,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { COVERED_CITIES, PRIMARY_CITY } from "../../content"; -import { describeCoverage } from "../../coverage-copy"; +import { describeCityExpectation, describeCoverage } from "../../coverage-copy"; import type { CoverageVerdict } from "../../coverage-copy"; import { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; import { @@ -110,6 +110,7 @@ export function EstimateFlow() { const addressRef = useRef(null); const areaRef = useRef(null); + const answerRef = useRef(null); const coverageAbort = useRef(null); // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе @@ -140,6 +141,32 @@ export function EstimateFlow() { if (normalized) setRooms(normalized); }, []); + // Ответ дорисовывается НИЖЕ формы, а не вместо неё (так работает «Изменить + // параметры» в результате — форма остаётся заполненной под ним). На узком + // экране это значит, что после нажатия человек видит ровно ту же форму: + // заголовок ответа оказывается за нижней кромкой (замер на 375 px, + // 30.08.2026), и нажатие читается как «ничего не произошло». + // + // Уводим к ответу и переводим на него фокус. Фокус здесь не украшение: без + // него человек с клавиатуры остаётся на кнопке «Проверить мой дом» и + // следующим Tab уходит В ОБХОД ответа, а не в него. Живая область + // (`role="status"`) объявляет текст сама, но объявление не перемещает точку + // ввода. + // + // `scroll-behavior` фиксированной строкой в JS обходит настройку системы, + // поэтому анимацию спрашиваем у той же медиа-функции, что глушит остальную + // анимацию витрины (`prefers-reduced-motion` в landing-v3.module.css). + // `matchMedia` может отсутствовать (jsdom без стабов) — тогда просто без + // анимации. + useEffect(() => { + if (phase.kind !== "result" && phase.kind !== "failed") return; + const node = answerRef.current; + if (!node) return; + const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? true; + node.focus({ preventScroll: true }); + node.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" }); + }, [phase.kind]); + // Подсказки: debounce + отмена предыдущего запроса. // // Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в @@ -263,6 +290,10 @@ export function EstimateFlow() { } const showList = suggestions.length > 0 && !picked; + // Чего ждать от проверки в этом городе — ДО нажатия кнопки, а не после. + // Города в списке не равны по данным, и молчание об этом человек читает как + // «везде одинаково». + const cityExpectation = describeCityExpectation(city); return (
@@ -286,6 +317,13 @@ export function EstimateFlow() { + {cityExpectation && ( +
+

{cityExpectation.text}

+

{cityExpectation.source}

+
+ )} + {/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и физически перекрывает поля «Комнат» и «Площадь», в которые человек как раз собрался попасть. onBlur на контейнере, а не на инпуте, — @@ -412,7 +450,15 @@ export function EstimateFlow() { {/* Живая область постоянно в DOM: регион, добавленный в момент ответа, часть скринридеров не озвучивает. */} -
+
{fieldError === "address" && (

diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 6df3344b..42246c37 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -22,6 +22,7 @@ * ноль вместо неизвестного значения был бы худшей из ошибок. */ +import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts"; import type { CoverageProbe } from "./public-api"; export interface CoverageTile { @@ -82,6 +83,37 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] { return tiles; } +export interface CityExpectation { + /** Что человек увидит рядом с выбранным городом ДО нажатия кнопки. */ + text: string; + /** Откуда величина: запрос, база, дата, размер выборки. */ + source: string; +} + +/** + * Половина городов дропдауна — не «отказ», но и не Екатеринбург. Замер лежит + * в `landing-facts.ts::CITY_COVERAGE`, здесь — только формулировка. + * + * ГОРОДА НЕ ДЕЛЯТСЯ НА «РАБОЧИЕ» И «НЕТ». Ни в одном из девяти проба не + * упирается в отказ систематически (худший — Ревда: 11 пустых из 100), так + * что выбрасывать города из списка не за что. Разница между ними + * количественная — её и показываем числом, а не отсутствием опции. + */ +export function describeCityExpectation(city: string): CityExpectation | null { + const measure = CITY_COVERAGE.find((m) => m.city === city); + if (!measure) return null; + + const rest = 100 - measure.confidentPct; + return { + text: + `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + + `из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` + + `будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` + + "не находится ни одной похожей квартиры.", + source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, + }; +} + export function describeCoverage(probe: CoverageProbe): CoverageVerdict { const where = probe.city ? `в городе ${probe.city}` : "по этому адресу"; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index a73b1bb2..bcd891b8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -105,3 +105,60 @@ export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года"; * независимо, и правка одного не касалась остальных. */ export const STEP1_FIELDS_LABEL = "6 полей"; + +/** + * Чего ждать от бесплатной пробы в каждом городе дропдауна. + * + * ЗАЧЕМ. В форме предлагается девять городов, и они не равны по данным. До + * этого замера человек узнавал об этом только ПОСЛЕ нажатия кнопки — из + * честного, но запоздалого «данные есть, но их мало». Предлагать выбор, + * ничего не говоря о его цене, — плохой продукт, даже когда отказ честен. + * + * ЧТО ИМЕННО ИЗМЕРЕНО (и чем это НЕ является). Не «объём базы» и не «число + * объявлений в городе». Измерена доля проб, которые вернули бы выборку не + * меньше городского порога `_COVERAGE_CITY_THRESHOLDS` + * (`backend/app/api/v1/trade_in.py`; порог 8 у ближнего круга, 12 у дальних + * городов) — то есть доля проверок, на которые сервис отвечает «данных + * хватает», а не «данных мало». + * + * ПОЧЕМУ НЕ СЧЁТ ПО `listings.city`. Эта колонка хранит город СВИПА скрейпера, + * а не геокод объявления (миграция 196, разбор над `_CITY_CENTROIDS_DEG` в + * `trade_in.py`): вокруг Берёзовского 90/90 строк лежат с city='Екатеринбург'. + * Счёт по ней даёт ноль по трём городам-спутникам и читается как «города нет + * в базе» — вывод неверный, ошибка в мерке. Города здесь резолвятся по + * координатам, тем же правилом ближайшего центроида, что и сама проба. + * + * ЧЕГО ЗАМЕР НЕ ЗНАЕТ. Точки взяты из адресов активных объявлений, а не из + * жилого фонда: там, где никто ничего не продаёт, мы не мерили. Свой адрес + * пробы исключён из когорты — иначе `emptyPct` был бы нулём по построению + * (объявление всегда попадает в собственный радиус), и «ни один город не + * пуст» оказалось бы свойством запроса, а не данных. + */ +export interface CityCoverageMeasure { + /** Лейбл ровно как в `OBLAST_CITIES` — по нему город и находится. */ + readonly city: string; + /** Доля проб с выборкой ≥ городского порога, % (округление до целого). */ + readonly confidentPct: number; + /** Доля проб, у которых рядом не нашлось ни одной похожей квартиры, %. */ + readonly emptyPct: number; + /** Сколько адресов прогнали. Где объявлений меньше — там и выборка меньше. */ + readonly sampleN: number; +} + +export const CITY_COVERAGE_SOURCE = + "Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " + + "активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " + + "число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " + + "собственный адрес из когорты исключён"; + +export const CITY_COVERAGE: readonly CityCoverageMeasure[] = [ + { city: "Екатеринбург", confidentPct: 83, emptyPct: 3, sampleN: 120 }, + { city: "Верхняя Пышма", confidentPct: 70, emptyPct: 5, sampleN: 120 }, + { city: "Серов", confidentPct: 58, emptyPct: 7, sampleN: 120 }, + { city: "Нижний Тагил", confidentPct: 52, emptyPct: 7, sampleN: 120 }, + { city: "Первоуральск", confidentPct: 50, emptyPct: 3, sampleN: 120 }, + { city: "Каменск-Уральский", confidentPct: 45, emptyPct: 6, sampleN: 120 }, + { city: "Среднеуральск", confidentPct: 39, emptyPct: 21, sampleN: 56 }, + { city: "Берёзовский", confidentPct: 38, emptyPct: 18, sampleN: 120 }, + { city: "Ревда", confidentPct: 16, emptyPct: 11, sampleN: 120 }, +];