fix(mera/b2c): увести к ответу проверки и показать ожидаемое качество по городу до нажатия

Экран проверки дорисовывал результат НИЖЕ формы и никуда не уводил: на 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 — добавить город в дропдаун,
не измерив его, теперь нельзя.
This commit is contained in:
bot-backend 2026-08-30 00:12:26 +05:00
parent ba35c68eb2
commit bb4a6d220f
4 changed files with 178 additions and 3 deletions

View file

@ -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();
});
});

View file

@ -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<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null);
const answerRef = useRef<HTMLDivElement>(null);
const coverageAbort = useRef<AbortController | null>(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 (
<div className={styles.estCard}>
@ -286,6 +317,13 @@ export function EstimateFlow() {
</select>
</label>
{cityExpectation && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackText}>{cityExpectation.text}</p>
<p className={styles.heroFormFeedbackText}>{cityExpectation.source}</p>
</div>
)}
{/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и
физически перекрывает поля «Комнат» и «Площадь», в которые человек
как раз собрался попасть. onBlur на контейнере, а не на инпуте,
@ -412,7 +450,15 @@ export function EstimateFlow() {
{/* Живая область постоянно в DOM: регион, добавленный в момент ответа,
часть скринридеров не озвучивает. */}
<div id={statusId} role="status" aria-live="polite">
<div
id={statusId}
ref={answerRef}
// Точка, в которую уводится фокус после ответа (см. эффект выше).
// -1: программно достижима, из таб-порядка не торчит.
tabIndex={-1}
role="status"
aria-live="polite"
>
{fieldError === "address" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackText}>

View file

@ -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}` : "по этому адресу";

View file

@ -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 },
];