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:
parent
ba35c68eb2
commit
bb4a6d220f
4 changed files with 178 additions and 3 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}` : "по этому адресу";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue