Compare commits

..

21 commits

Author SHA1 Message Date
d3b0d0bea5 Merge branch 'main' into feat/mera-hero-separate-caveats
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m29s
2026-08-31 09:28:32 +00:00
1b7d10179f Merge pull request 'липкая панель прячется, пока герой в кадре' (#3296) from fix/mera-sticky-after-hero into main
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-08-31 09:28:15 +00:00
e94a9792ce Merge branch 'main' into fix/mera-sticky-after-hero
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
2026-08-31 09:26:41 +00:00
86e6956257 Merge pull request 'лендинг: пятая плитка падала на вторую строку, столбец «Расхождение» читался пустым' (#3290) from fix/mera-kpi-grid-and-table into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-08-31 09:26:34 +00:00
98a1944a86 Merge pull request 'числа лендинга: покрытие 88 % было выше всего диапазона представительных пересборок' (#3295) from fix/mera-honest-coverage-number into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-31 09:26:28 +00:00
b277b1f678 Merge branch 'main' into fix/mera-sticky-after-hero
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
2026-08-31 09:24:43 +00:00
3f57171f63 Merge branch 'main' into fix/mera-kpi-grid-and-table
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
2026-08-31 09:24:37 +00:00
44e7d4b63a Merge branch 'main' into fix/mera-honest-coverage-number
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
2026-08-31 09:15:06 +00:00
f7d428c0c5 Merge pull request 'витрина: строка без улицы больше не открывает страницу и игру' (#3291) from fix/mera-showcase-order-street into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Trade-In / build-frontend (push) Successful in 2m41s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-31 09:14:48 +00:00
d20b844561 Merge pull request 'бэктест: выборка вразброс — ORDER BY id DESC садится на несколько улиц' (#3289) from feat/backtest-scattered-sampling into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-31 09:14:40 +00:00
078f102841 Merge pull request '«Для бизнеса» вела на форму входа — заведена страница /business' (#3294) from feat/mera-business-page into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Infra Host / sync-infra-host (push) Successful in 10s
Deploy / changes (push) Successful in 14s
perimeter-smoke-mera / smoke (push) Failing after 16s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Failing after 13s
2026-08-31 09:14:34 +00:00
47df1a9020 липкая панель прячется, пока герой в кадре
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m37s
Владелец увидел на первом экране, что полоса «Проверить квартиру» лежит поверх
абзаца «Мы называем реальную цену вашей квартиры».

.barSpacer это НЕ лечит и не может: он добавляет высоту в КОНЕЦ документа, а
.barRoot — fixed, то есть накрывает нижние ~67px вьюпорта при ЛЮБОЙ прокрутке,
включая scrollTop=0. Разобравший это агент так и написал и не стал чинить молча.

Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, и
полоса дублирует призыв, закрывая текст. Уходит герой — панель появляется.

ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА. Скрытие включает только клиентский наблюдатель;
если скрипт не выполнился, поведение остаётся ровно сегодняшним. Обратный
порядок (скрыта, показывает скрипт) в тех же условиях убрал бы призыв со
страницы совсем — то есть чинил бы вид ценой работы. Нет секции героя —
наблюдать нечего, панель остаётся видимой, а не пропадает.

Убирается transform-ом, а не display:none: transform идёт на композиторе и не
вызывает перекладку. Страница и так дорога в отрисовке на мобильном (1824 мс в
Style & Layout), и лечение перекрытия не должно стоить пересчёта макета на
каждом пересечении границы героя.

Убранная панель уходит и из дерева доступности (aria-hidden + inert): иначе Tab
уводит в кнопку, которой на экране нет.

Тест проверяет ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
пересечение. Фальсификация обеих сторон — панель не прячется («expected false to
be true») и панель скрыта по умолчанию («до срабатывания панель видима»).
150 passed, eslint чисто.
2026-08-31 14:13:36 +05:00
da6a6b6e82 Merge pull request 'P1: лендинг отдавался без витрины — имя сервиса двоится между продуктами' (#3282) from fix/mera-ambiguous-service-names into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m29s
Deploy Trade-In / test (push) Has been cancelled
2026-08-31 09:10:09 +00:00
c4beee29ff для бизнеса: убраны обещания, которых нет в контуре; гейт чисел накрыл подстраницы
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
Снимает два замечания ревью.

1. КОПИЯ ОБЕЩАЛА НЕСУЩЕСТВУЮЩЕЕ. «Расчёты по потоку адресов», «не для одной
сделки, а потоком», «по вашему потоку адресов» — всё это подразумевает
массовую загрузку, которой в контуре нет: ни CSV, ни импорта, ни пакетной
ручки. Проверено grep-ом по src/app/v2.

Что там ЕСТЬ на самом деле и теперь названо: учётные записи команды
(app/api/v1/team.py), история своих расчётов (useEstimateHistory, оверлей
«Предыдущие оценки»), месячный лимит на аккаунт (account_quota_overrides,
monthly_limit/unlimited), выдача доступа вручную.

Это ровно тот дефект, который на этом лендинге выкорчёвывали весь день:
величина или возможность, которой никто не мерил и не делал, в продуктовом
голосе. Новая страница не имеет права начинать заново.

2. ГЕЙТ ЧИСЕЛ НЕ ВИДЕЛ НОВУЮ СТРАНИЦУ. Сканер знал два места: корневой
page.tsx и каталог _components/v3. Публичная /business под него не попадала —
правило держалось на памяти автора. Теперь подстраницы находятся ОБХОДОМ
каталога, а не списком: список — снимок, следующую страницу в него снова
забудут вписать.

Юридические страницы (оферта, политика, возврат, документы) исключены явно и
с обоснованием: они состоят из ссылок на законы («Закон РФ от 07.02.1992
№ 2300-1», «152-ФЗ», «ст. 18.1»), а по форме это те же числа, что и замеры.
Отличить регулярным выражением нельзя, и правило гейта к ним не относится —
там числа цитируются из нормативных актов, а не утверждаются о рынке.
Расширение проверено: включение этих страниц давало 4 ложных срабатывания.

Фальсификация: вписанные в /business «42 700 квартир» роняют гейт по значению
с указанием файла. 150 passed, tsc и eslint чисто.
2026-08-31 13:02:45 +05:00
79ae87019a бэктест: подписи окна приведены к замеру, уверенность — к тому же прогону
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m21s
Снимает три блокера ревью на этой же ветке.

1. ПОДПИСЬ ОКНА. Числа перевели на представительную выборку, а подпись
осталась от прежнего прогона: «сделки II квартала 2026 года». Автор честно
записал, что квартальный состав пересборки не смотрел, и оставил критерий
снятия — посчитать deal_date по выборке. Посчитал:

    III кв 2025 — 106 (26,5 %)      I кв 2026 — 88 (22,0 %)
    IV кв 2025  — 110 (27,5 %)     II кв 2026 — 96 (24,0 %)

Пересборка покрывает ЧЕТЫРЕ квартала, II квартал 2026 — четверть её. Прежняя
подпись для этих чисел была бы прямым враньём.

2. ЗНАМЕНАТЕЛЬ. Был 5 954 — сделки только II квартала. Для выборки по четырём
кварталам это завышало долю вчетверо: 5,5 % вместо 1,3 %. Замер тем же
фильтром, что у выборки: в окне 24 333, из них II квартал 2026 — 5 954. Оба
числа одним запросом.

3. УВЕРЕННОСТЬ. Плитка «325 из 327» была из прогона 29.08, а лид секции печатал
«замер 31.08» — старому числу приписывалась новая дата, и 325 в лиде совпадало
с 325 в плитке, читаясь как одна выборка. Заменено на «400 из 400» из ТОЙ ЖЕ
пересборки. Знаменатель 400 — запрошенные сделки, а не 325 сматчившихся:
уверенность считается на каждой оценке.

4. Оговорка уверенности не выводилась вовсе: AccuracyV3 собирал оговорки двух
величин из трёх. Плитка стояла без единого слова о том, почему уверенность
низкая. Добавлена третья.

Тесты проверены фальсификацией: возврат знаменатели одного квартала даёт
«expected 5954 to be 24333» и «expected 5.458 to be less than 2»; снятие
оговорки из вывода — «оговорка не выводится». 157 passed.
2026-08-31 12:59:27 +05:00
2864d80854 fix(mera-public): честные числа бэктеста — медиана трёх представительных пересборок
Покрытие 88 % и расхождение 14,5 % получались только на кластеризованной
выборке (ORDER BY id DESC: 20 строк на четыре улицы). Замер 31.08.2026 тремя
представительными пересборками (engine=full, ЕКБ, три соли) дал покрытие
82,77/84,31/86,02 % и MAPE 14,66/15,28/15,66 %: опубликованные 88 % лежат ВЫШЕ
всего диапазона, то есть недостижимы, а 14,5 % — чуть ниже, тот же
благоприятный край.

- BACKTEST.coverage 88 % -> 84,3 %, priceError 14,5 % -> 15,3 % (медианы),
  sampleN 327 -> 325 (медианная пересборка);
- в оговорки добавлен сам диапазон пересборок (82,8-86,0 % и 14,7-15,7 %):
  точечное значение без него обещает точность, которой у замера нет;
- BACKTEST_SOURCE переписан — представительная выборка, а не последние
  загруженные строки; дата замера 29.08 -> 31.08.2026;
- confidenceLow («325 из 327») на пересборках НЕ пересчитывался: у него
  отдельный источник с прежней датой и оговорка про свой знаменатель;
- квартальный состав представительной выборки не проверен — записан
  дословно, с критерием снятия, рядом с BACKTEST_PERIOD_LABEL.

Тест: backtest-freshness проверяет наличие диапазона в оговорках (+контроль на
инструмент) и что AccuracyV3/HeroV3 не содержат самих величин; render-тест
перестал сверяться с вписанным «14,5 %» и берёт значение из landing-facts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:41:32 +05:00
4d51d19f7c feat(mera-public): публичная страница /business вместо ссылки на форму входа
Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале вели на B2B_URL.
Реальная цепочка на проде: 308 → /trade-in → 307 → /trade-in/v2 → клиентский
редирект → /trade-in/login. Посетитель публичного сайта упирался в форму
«Логин / Пароль» от аккаунта, которого у него нет.

Заведена страница /business: кому полезен бизнес-контур, чем он отличается от
бесплатной проверки, и как связаться (почта и телеграм — те же константы, что
в подвале и в документах, не выдуманные для страницы). Ни цен, ни сроков, ни
объёмов: этих величин никто не мерил. Ссылка на вход оставлена внутри страницы
второстепенной — «уже работаете с нами».

Периметр согласован во всех четырёх местах: PUBLIC_ROUTES, три матчера Caddy
(@meraPages, @meraShortSlash, @meraLongPages), PUBLIC_SHORT_PATHS в RouteGuard,
плюс строка в smoke-mera-perimeter.sh. Гейт периметра проверен фальсификацией:
удаление /business из @meraShortSlash роняет public-perimeter.test.ts.

noindex — тем же способом, что у /docs (metadata.robots).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:40:59 +05:00
884a6b9cfe бэктест: режим выборки вразброс — ORDER BY id DESC садится на несколько улиц
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние id это один дом и одна улица. Замер на проде
31.08.2026 по Екатеринбургу, выборка 200:

    последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
    случайные 200       : 129 разных улиц, максимум 6 с одной

На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с
двух. Владелец заметил это как «почти все сделки из одного района».

ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок:

    recent            MAPE 15.18%  покрытие 88.75%
    scattered mera    MAPE 14.96%  покрытие 83.13%
    scattered alpha   MAPE 15.97%  покрытие 88.34%
    scattered bravo   MAPE 13.69%  покрытие 83.85%
    scattered charlie MAPE 14.36%  покрытие 86.23%

Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса
представительной выборки. Кластеризация её не раздувала.

А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке
выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон
из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом.

УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово
тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы
сравнимость всей истории замеров. Режим выбирается флагом --spread.

Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе
два прогона отличаются и из-за правки, и из-за состава выборки, и разделить
вклады нечем.

Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием
— падает identity-проверка (сравнение текстов её бы пропустило), заменить md5
на random() — падает проверка воспроизводимости.
2026-08-31 11:28:11 +05:00
28bf3a82d7 fix(mera-public): плитки точности в один ряд, «Расхождение» по одному краю
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m21s
Пятая плитка (тёмная, из /stats) не влезала в грид: контейнер 1168px,
minmax(220px) + gap 20px требуют 1180px — плитка падала на вторую строку
одна и узкая, читалось как обрыв вёрстки. Заменил грид на flex-wrap с общим
базисом 200px: пять плиток встают в ряд, четыре — тоже, а на узких ширинах
остаток растягивается на всю строку вместо огрызка слева.

Значения столбца «Расхождение» стояли по левому краю ячейки, а заголовок —
по правому, из-за чего столбец читался пустым. Выровнял значения вправо,
по краю заголовка; на мобильном брейке text-align по-прежнему сбрасывается
в left (карточки с data-label не тронуты).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:24:11 +05:00
e45b05845e fix(mera-landing): наличие схемы улицы входит в полноту строки витрины
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.

Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.

Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.

Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:22:39 +05:00
f31cb56081 прод: лендинг ходил в чужой бэкенд — имя сервиса двоится между продуктами
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 17m35s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m11s
Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта», без подписи разброса. Страница про точность — без
единого доказательства. Отдавалось 106 КБ вместо 241 КБ.

Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис
backend и оба подключены к общей сети gendesign_shared. Изнутри фронта:

    backend         → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
    tradein-backend → 172.18.0.6

BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот
отвечал 401 no authenticated user, и страница рендерилась пустой.

Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто
чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес:
утром страница была с данными, к обеду без них, и это выглядело случайной
поломкой, а не ошибкой конфигурации.

DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало
лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться
на это нельзя: защита держится на том, что у чужой базы нет пользователя с
нашим паролем. Переведён на однозначное имя во всех трёх сервисах.

Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose
и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов —
он провалился дважды на моих же фикстурах (в них не было общего имени, то есть
ловить было нечего), и это ровно то, ради чего селфтест и нужен.

Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479,
возврат @postgres — все четыре ссылки.
2026-08-30 16:07:41 +05:00
24 changed files with 1097 additions and 66 deletions

View file

@ -82,6 +82,22 @@ jobs:
python3 scripts/check-caddy-snippet-mounts.py --selftest
python3 scripts/check-caddy-snippet-mounts.py
- name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя"
# ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы
# `backend`/`postgres`/`frontend` и оба сидят в общей сети
# gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент
# берёт любой.
#
# 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд
# ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ
# ленты сделок, без строк сверки и без подписи разброса — то есть без
# единого доказательства. Отказ тихий: fetch не бросает, приходит
# валидный чужой ответ; а из-за двоения часть перегенераций попадала
# в правильный адрес, и поломка выглядела случайной.
run: |
python3 scripts/check-compose-ambiguous-hosts.py --selftest
python3 scripts/check-compose-ambiguous-hosts.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.

View file

@ -347,7 +347,7 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@ -361,7 +361,7 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
}
@ -446,7 +446,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282).
ПОЧЕМУ. ПТИЦА и МЕРА разные compose-проекты, но оба назвали свои сервисы
`backend`, `postgres`, `frontend`, и оба подключены к общей сети
`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой:
изнутри tradein-frontend:
backend 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
tradein-backend 172.18.0.6
30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000`
уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated
user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта» и без подписи разброса. Страница про точность без
единого доказательства.
Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP-
ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций
попадала в правильный адрес утром страница отдавалась с данными, к обеду без
них, и выглядело это как случайная поломка, а не как ошибка конфигурации.
У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу
ПТИЦЫ. Там спасало лишь несовпадение кредов то есть отказ вместо тихого
чтения не тех данных. На такую защиту полагаться нельзя: она держится на том,
что у чужой базы нет пользователя с нашим паролем.
ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть
двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост
ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно:
подставить `container_name` (`tradein-backend`, `tradein-postgres`), он
однозначен по построению.
ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в
контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба
compose-файла.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml"
PTICA_COMPOSE = REPO / "docker-compose.prod.yml"
# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие.
_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M)
# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN).
_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+")
def service_names(text: str) -> set[str]:
"""Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они
объявляются в своих секциях с тем же отступом, поэтому список заведомо шире
нужного и это безопасная сторона, лишнее имя даст лишнюю проверку, а не
пропуск."""
return set(_SERVICE_RE.findall(text))
def violations(mera: str, ptica: str) -> list[str]:
ambiguous = service_names(mera) & service_names(ptica)
if not ambiguous:
return []
found: list[str] = []
for num, line in enumerate(mera.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
for host in _HOST_RE.findall(line):
if host in ambiguous:
found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}")
return found
def selftest() -> int:
"""Гейт обязан уметь краснеть — иначе зелёный ничего не значит."""
ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n"
# У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение
# пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить».
# На этом селфтест и провалился в первой редакции.
bad = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://backend:8000\"\n"
)
if not violations(bad, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr)
return 1
bad_dsn = (
"services:\n postgres:\n image: y\n"
" backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n"
)
if not violations(bad_dsn, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr)
return 1
ok = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n"
)
if violations(ok, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr)
return 1
# Закомментированная строка — не конфигурация.
commented = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n # URL: \"http://backend:8000\"\n"
)
if violations(commented, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr)
return 1
print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть")
args = ap.parse_args()
if args.selftest:
return selftest()
for path in (MERA_COMPOSE, PTICA_COMPOSE):
if not path.exists():
print(f"НЕ НАЙДЕН: {path}", file=sys.stderr)
return 1
mera = MERA_COMPOSE.read_text(encoding="utf-8")
ptica = PTICA_COMPOSE.read_text(encoding="utf-8")
shared = service_names(mera) & service_names(ptica)
if not shared:
print("пересекающихся имён сервисов нет — проверять нечего")
return 0
found = violations(mera, ptica)
if not found:
print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет")
return 0
print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr)
for line in found:
print(f" {line}", file=sys.stderr)
print(
"\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n"
"в два адреса и клиент берёт любой. Подставьте container_name\n"
"(tradein-backend, tradein-postgres) — он однозначен.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -90,6 +90,11 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт
# шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер

View file

@ -30,8 +30,8 @@
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность),
у строки заполнено. Свежесть порядок квартала сделки.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
----------------------------------------------------------
@ -69,7 +69,10 @@
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`):
строка, которой нечем нарисовать карту, полнее строки с картой быть не
может. Это признак «поле заполнено», как район и этаж, а не величина
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@ -97,7 +100,7 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.street_scheme import build_street_scheme, load_street_index
from app.services.street_scheme import StreetIndex, build_street_scheme, load_street_index
logger = logging.getLogger(__name__)
@ -158,15 +161,26 @@ class ShowcaseRow:
# None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None
lon: float | None = None
# Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM).
# Саму схему строим только для показанных строк — см. `_schemes_for`.
has_street: bool = False
def completeness(row: ShowcaseRow) -> int:
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3).
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..4).
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по
построению выборки, поэтому в оценке полноты они бесполезны.
Считаем район/этаж/этажность и схему улицы: комнаты и площадь есть у всех
кандидатов по построению выборки, поэтому в оценке полноты они бесполезны.
СХЕМА УЛИЦЫ ТАКОЕ ЖЕ ВИДИМОЕ ПОЛЕ, как район. Строка без улицы рисует на
фронте полигон РАЙОНА вместо схемы улиц, то есть этого поля у неё просто
нет, и полнее строки с картой она быть не может. Это признак наличия
данных, а не величина ошибки: строка с большим отклонением, но со схемой,
показывается как есть.
"""
return sum(x is not None for x in (row.district, row.floor, row.total_floors))
return int(row.has_street) + sum(
x is not None for x in (row.district, row.floor, row.total_floors)
)
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
@ -197,6 +211,7 @@ def build_row(
n_analogs: int,
lat: float | None = None,
lon: float | None = None,
has_street: bool = False,
) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего.
@ -251,6 +266,7 @@ def build_row(
n_analogs=n_analogs,
lat=lat,
lon=lon,
has_street=has_street,
)
@ -320,19 +336,27 @@ _INSERT_SQL = text(
)
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
def _schemes_for(
db: Session,
index: StreetIndex,
chosen: list[ShowcaseRow],
addresses: dict[int, str | None],
) -> dict:
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
Считаем после отбора, а не до: схема это два пространственных запроса на
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
лишних запросов в чужую базу.
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ и это то же правило, что запрещает отбор
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
OSM. Схема украшение строки, а не причина её показать.
НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы
входит наравне с районом и этажом. До 2026-08-31 здесь было записано
обратное «схема на отбор не влияет», и первой строкой витрины и первым
раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо
схемы улиц рисовался полигон района. Отсутствие видимого поля не может
делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не
затронут: строка с большим отклонением, но со схемой, стоит в витрине как
есть.
"""
index = load_street_index(db)
out = {}
for row in chosen:
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
@ -377,6 +401,10 @@ def refresh_landing_showcase_deals(
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
districts = _fetch_districts(db, [d.id for d in deals])
# Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак
# полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на
# прогон, дорогие пространственные запросы остались в `_schemes_for`.
street_index = load_street_index(db)
candidates: list[ShowcaseRow] = []
n_priced = 0
@ -408,6 +436,7 @@ def refresh_landing_showcase_deals(
# тип этого не поймает: обе величины float.
lat=deal.lat,
lon=deal.lon,
has_street=street_index.lookup(deal.address) is not None,
)
if row is None:
n_incomplete += 1
@ -415,7 +444,7 @@ def refresh_landing_showcase_deals(
candidates.append(row)
chosen = select_rows(candidates, limit)
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals})
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL)

View file

@ -1129,6 +1129,88 @@ _SAMPLE_SQL = text(
"""
)
# ── ВЫБОРКА ВРАЗБРОС (--spread scattered) ────────────────────────────────────
#
# ЗАЧЕМ. `ORDER BY id DESC` выше — это последние ВСТАВЛЕННЫЕ строки, а Росреестр
# грузится пачками по домам: соседние id это один дом и одна улица. Замер на
# проде 31.08.2026 по Екатеринбургу, выборка 200:
#
# последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
# случайные 200 : 129 разных улиц, максимум 6 с одной
#
# То есть заголовочная точность и вся витрина лэндинга считались по выборке
# втрое беднее случайной. Ошибка при этом не «шумит», а СМЕЩЕНА: пара новостроек
# ведёт себя иначе, чем город.
#
# ПОЧЕМУ НЕ СТАЛО УМОЛЧАНИЕМ. Замороженный регресс-гейт сравнивает прогоны между
# собой и требует от дефолтного пути побайтово того же SQL (см. докстринг
# `_sample_sql`). Менять умолчание значило бы обнулить сравнимость всей истории
# замеров. Режим выбирается явно.
#
# ПОЧЕМУ md5, А НЕ random(). `random()` даёт разную выборку на каждом прогоне —
# два замера подряд отличались бы и из-за правки, и из-за выборки, и разделить
# эти вклады было бы нечем. md5(id || seed) — псевдослучайный, но
# ВОСПРОИЗВОДИМЫЙ порядок: тот же seed → та же выборка.
_SAMPLE_SQL_SCATTERED = text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
price_rub,
deal_date,
area_m2,
address,
floor,
total_floors,
year_built,
house_type
FROM deals
WHERE source = 'rosreestr'
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND area_m2 > 0
AND deal_date >= CAST(:since AS date)
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
LIMIT CAST(:sample AS integer)
"""
)
_SAMPLE_SQL_SCATTERED_CITY = text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
price_rub,
deal_date,
area_m2,
address,
floor,
total_floors,
year_built,
house_type
FROM deals
WHERE source = 'rosreestr'
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND area_m2 > 0
AND deal_date >= CAST(:since AS date)
AND city = CAST(:city AS text)
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
LIMIT CAST(:sample AS integer)
"""
)
# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
# is applied in Python for byte-for-byte fidelity with the estimator.
@ -1163,7 +1245,7 @@ _CITY_PPM2_BAND_SQL = text(
)
def _sample_sql(city: str | None) -> Any:
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
@ -1177,6 +1259,8 @@ def _sample_sql(city: str | None) -> Any:
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
slug see the ``--city`` CLI help for the naming decision.
"""
if scattered:
return _SAMPLE_SQL_SCATTERED_CITY if city is not None else _SAMPLE_SQL_SCATTERED
if city is None:
return _SAMPLE_SQL
return text(
@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
def _load_sample(
db: Session, *, sample: int, since: str, city: str | None = None
db: Session,
*,
sample: int,
since: str,
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
@ -1259,7 +1349,9 @@ def _load_sample(
}
if city is not None:
params["city"] = city
rows = db.execute(_sample_sql(city), params).mappings().all()
if scattered:
params["seed"] = seed
rows = db.execute(_sample_sql(city, scattered), params).mappings().all()
out: list[DealSample] = []
for r in rows:
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
@ -2025,6 +2117,8 @@ def run_backtest(
rooms_tolerance: int,
holdout_split: bool = False,
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict.
@ -2044,7 +2138,7 @@ def run_backtest(
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour).
"""
deals = _load_sample(db, sample=sample, since=since, city=city)
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
matched_rows: list[tuple[float, float, int]] = []
@ -2118,6 +2212,8 @@ def run_backtest_full(
dump_fixture: str | None = None,
resolve_house_id: bool = False,
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -2150,7 +2246,7 @@ def run_backtest_full(
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since, city=city)
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
)
@ -2318,6 +2414,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default="2025-06-01",
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
)
p.add_argument(
"--spread",
choices=("recent", "scattered"),
default="recent",
help="Как берётся выборка сделок. 'recent' (умолчание) — ORDER BY id DESC, "
"т.е. последние ВСТАВЛЕННЫЕ строки: Росреестр грузится пачками по домам, "
"поэтому такая выборка садится на несколько улиц (замер 31.08.2026 по ЕКБ: "
"38 улиц, до 22 сделок с одной). 'scattered' — воспроизводимый "
"псевдослучайный порядок по md5(id||seed): 129 улиц, максимум 6 с одной. "
"Умолчание оставлено 'recent' ради сравнимости с историей замеров — "
"замороженный регресс-гейт требует от дефолтного пути того же SQL.",
)
p.add_argument(
"--seed",
default="mera",
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
"прогона подряд отличаются только правкой, а не составом выборки.",
)
p.add_argument(
"--city",
default=None,
@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int:
dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id,
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
)
else:
metrics = run_backtest(
@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int:
rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split,
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
)
finally:
db.close()

View file

@ -0,0 +1,88 @@
"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр
грузится пачками по домам: соседние id это один дом и одна улица. Замер на
проде 31.08.2026 по Екатеринбургу, выборка 200:
последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
случайные 200 : 129 разных улиц, максимум 6 с одной
Выборка втрое беднее случайной а на ней стояла витрина лэндинга (20 строк с
ЧЕТЫРЁХ улиц, 16 из них с двух).
ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой:
1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed.
2. УМОЛЧАНИЕ НЕ ТРОНУТО дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL.
Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание
значило бы молча обнулить сравнимость всей истории замеров.
"""
from __future__ import annotations
import re
from scripts.backtest_estimator import (
_SAMPLE_SQL,
_SAMPLE_SQL_SCATTERED,
_SAMPLE_SQL_SCATTERED_CITY,
_sample_sql,
)
def _sql(obj) -> str:
return str(obj.text if hasattr(obj, "text") else obj)
class TestУмолчаниеНеТронуто:
def test_дефолтный_путь_отдаёт_тот_же_объект(self):
# Именно identity, а не равенство текста: докстринг `_sample_sql`
# обещает побайтовую неизменность, и «текст совпал» это обещание не
# проверяет — объект мог быть пересобран с другими bind-параметрами.
assert _sample_sql(None) is _SAMPLE_SQL
assert _sample_sql(None, scattered=False) is _SAMPLE_SQL
def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self):
sql = _sql(_SAMPLE_SQL)
assert "ORDER BY id DESC" in sql
assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс"
class TestРежимВразброс:
def test_выбирается_явно_и_меняет_порядок(self):
assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED
assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY
def test_порядок_воспроизводим_ае_случаен(self):
# random() дал бы разную выборку на каждом прогоне, и два замера
# подряд отличались бы и из-за правки, и из-за состава выборки —
# разделить вклады было бы нечем.
for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY):
sql = _sql(obj)
assert "md5(" in sql, "порядок обязан считаться от id и seed"
assert ":seed" in sql
assert re.search(r"\brandom\s*\(", sql) is None, (
"random() делает выборку неповторимой между прогонами"
)
assert "ORDER BY id DESC" not in sql
def test_городской_вариант_фильтрует_город_а_общий_нет(self):
assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY)
assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED)
def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self):
# Иначе разница между режимами объяснялась бы не порядком, а другим
# набором сделок — и сравнение двух прогонов ничего бы не значило.
общие = [
"source = 'rosreestr'",
"geom IS NOT NULL",
"price_per_m2 BETWEEN",
"rooms IS NOT NULL",
"area_m2 > 0",
"deal_date >= CAST(:since AS date)",
]
база = _sql(_SAMPLE_SQL)
вразброс = _sql(_SAMPLE_SQL_SCATTERED)
for условие in общие:
assert условие in база, условие
assert условие in вразброс, условие

View file

@ -31,6 +31,7 @@ def _row(
total_floors: int | None = 9,
deal_date: date = date(2026, 1, 1),
err_pct: float = 10.0,
has_street: bool = True,
) -> ShowcaseRow:
return ShowcaseRow(
deal_id=deal_id,
@ -45,6 +46,7 @@ def _row(
fact_rub=5_500_000,
err_pct=err_pct,
n_analogs=40,
has_street=has_street,
)
@ -72,6 +74,31 @@ def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
def test_selection_prefers_row_with_street_scheme() -> None:
"""Строка без улицы не может быть самой полной: ей нечем нарисовать карту.
Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры
«Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо
схемы улиц у неё рисовался полигон РАЙОНА.
Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы
выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из
`completeness` тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
Вторая сторона проверки строка без улицы ОСТАЁТСЯ в витрине: она не
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
"""
no_street = _row(9, has_street=False, err_pct=75.7)
with_street = _row(8, err_pct=3.0)
chosen = select_rows([no_street, with_street], limit=2)
assert [r.deal_id for r in chosen] == [8, 9], (
"строка, которой нечем нарисовать карту, оказалась полнее строки со "
"схемой улиц — витрина открывается полигоном района"
)
def test_selection_is_deterministic_on_full_ties() -> None:
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
rows = [_row(7), _row(9), _row(8)]

View file

@ -294,7 +294,7 @@ services:
# Деградация источника в None — существующее состояние ответа (так же
# ведёт себя таймаут), контракт API не меняется.
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
PUBLIC_URL: "https://gendsgn.ru/trade-in"
CORS_ORIGINS: '["https://gendsgn.ru"]'
ENVIRONMENT: "production"
@ -373,7 +373,7 @@ services:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
@ -432,7 +432,7 @@ services:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
# Этот процесс — не scheduler_main; false на всякий случай, если общий
@ -468,7 +468,15 @@ services:
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
# Принудительно 0.0.0.0 чтобы Caddy достучался.
HOSTNAME: "0.0.0.0"
BACKEND_URL: "http://backend:8000" # internal SSR
# ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис
# `backend`, и оба подключены к общей сети gendesign_shared —
# изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6
# и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает
# 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и
# подписи разброса, то есть страница про точность без доказательств.
# Отказ тихий: fetch не бросает, приходит валидный 401.
# `tradein-backend` — container_name, он однозначен.
BACKEND_URL: "http://tradein-backend:8000" # internal SSR
depends_on: [backend]
restart: unless-stopped
networks:

View file

@ -19,6 +19,9 @@
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
* Срок = квартал плюс запас на загрузку следующей пачки.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
@ -78,3 +81,55 @@ describe("свежесть ручного замера бэктеста", () =>
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
});
});
/**
* Разброс пересборок и происхождение величин.
*
* ЗАЧЕМ. 31.08.2026 три представительные пересборки одной и той же сверки дали
* попадание 82,8-86,0 % и расхождение 14,7-15,7 %. Опубликованные до этого
* 88 % лежали ВЫШЕ всего диапазона значение получалось только на
* кластеризованной выборке (`ORDER BY id DESC`, 20 строк на четыре улицы).
* Точечное число без диапазона обещает точность, которой у замера нет, поэтому
* оговорка про разброс не украшение, а часть величины: снимут её красное.
*
* ВТОРАЯ ПОЛОВИНА откуда число берёт компонент. Прежние 88 % пережили бы
* правку `landing-facts.ts`, будь они вписаны в разметку; здесь проверяется,
* что в витринных компонентах нет самих значений, а есть ссылки на факты.
*/
const V3 = join(__dirname, "..", "_components", "v3");
const SPREAD_RANGES = ["14,7-15,7", "82,8-86,0"];
function caveatsText(): string {
return [...BACKTEST.priceError.caveats, ...BACKTEST.coverage.caveats].join(" ");
}
describe("разброс пересборок назван, а величины не вписаны в компонент", () => {
it.each(SPREAD_RANGES)("оговорка называет диапазон пересборок %s", (range) => {
expect(
caveatsText(),
[
`в оговорках priceError/coverage нет диапазона ${range}.`,
"Точечное значение разового замера гуляет при пересборке выборки —",
"без диапазона витрина обещает точность, которой у замера нет.",
].join(" "),
).toContain(range);
});
it("проверка диапазона краснеет на тексте без него — иначе она зелёная по построению", () => {
const withoutRange = "Попадание достигнуто шириной коридора, а не точностью точки.";
for (const range of SPREAD_RANGES) {
expect(withoutRange).not.toContain(range);
}
});
it.each(["AccuracyV3.tsx", "HeroV3.tsx"])("%s берёт величины из landing-facts, а не из своей разметки", (file) => {
const src = readFileSync(join(V3, file), "utf8");
for (const value of [BACKTEST.priceError.text, BACKTEST.coverage.text]) {
expect(
src,
`${file}: величина ${value} вписана в компонент — правка landing-facts её не догонит`,
).not.toContain(value);
}
expect(src, `${file}: величина берётся не из BACKTEST`).toContain("BACKTEST.");
});
});

View file

@ -0,0 +1,68 @@
/**
* Подписи бэктеста обязаны описывать ТО окно, по которому числа посчитаны.
*
* 31.08.2026 числа перевели на представительную выборку (медиана трёх
* пересборок), но подписи остались от прежнего прогона: «сделки II квартала
* 2026 года» и знаменатель 5 954 (только этот квартал). Замер показал, что
* пересборка покрывает ЧЕТЫРЕ квартала и II квартал 2026 лишь 24 % её
* сделок. Доля выборки при старом знаменателе выходила 5,5 % вместо 1,3 %,
* то есть вчетверо льстила.
*
* Отдельно: плитка «уверенность низкая» показывалась БЕЗ своей оговорки
* `AccuracyV3` собирал оговорки только двух величин из трёх.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import {
BACKTEST,
BACKTEST_PERIOD_LABEL,
BACKTEST_POPULATION,
BACKTEST_SHARE_LABEL,
} from "../landing-facts";
describe("подписи окна бэктеста", () => {
it("окно названо целиком, а не одним кварталом из четырёх", () => {
// Прежняя подпись называла II квартал 2026 — в представительной выборке
// это 24 % сделок, а не все.
expect(BACKTEST_PERIOD_LABEL).not.toMatch(/^сделки II квартала/u);
expect(BACKTEST_PERIOD_LABEL).toMatch(/четыре квартала/u);
});
it("знаменатель — того же окна, что и выборка", () => {
// 5 954 — только II квартал 2026; окно целиком — 24 333 (замер 31.08.2026).
expect(BACKTEST_POPULATION).toBe(24_333);
});
it("доля выборки выводится и получается около 1,3 %, а не 5,5 %", () => {
const share = (BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100;
expect(share).toBeGreaterThan(1.0);
expect(share).toBeLessThan(2.0);
expect(BACKTEST_SHARE_LABEL).toBe("1,3 %");
});
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
// приписывало старому числу новую дату.
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
});
});
describe("оговорки в интерфейсе", () => {
it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
render(<AccuracyV3 stats={{}} showcase={null} />);
for (const набор of [
BACKTEST.priceError.caveats,
BACKTEST.coverage.caveats,
BACKTEST.confidenceLow.caveats,
]) {
for (const line of набор) {
expect(
screen.queryByText(line),
`оговорка не выводится: ${line.slice(0, 48)}`,
).not.toBeNull();
}
}
});
});

View file

@ -79,8 +79,57 @@ const V3_DIR = "_components/v3";
const GENERATED = ["ekb-districts.ts"];
const GENERATED_MARK = "СГЕНЕРИРОВАН";
/**
* ПОДСТРАНИЦЫ ПЕРИМЕТРА тоже под гейтом, а не только корень и v3-секции.
*
* 31.08.2026 завели `/business`, и ревьюер заметил, что новая ПУБЛИЧНАЯ
* страница чисел ничем не сторожится: сканер знал ровно два места корневой
* `page.tsx` и каталог `_components/v3`. Чисел там тогда не было, но правило
* держалось на том, что автор о нём помнил.
*
* Каталоги перечисляются ОБХОДОМ, а не списком: список снимок, и следующую
* страницу в него снова забудут вписать. Обход находит её сам.
*/
/**
* ЮРИДИЧЕСКИЕ СТРАНИЦЫ ПОД ЭТОТ ГЕЙТ НЕ ПОПАДАЮТ и это не послабление.
*
* Гейт ищет ФОРМУ величины: «14,5 %», «42 700», «150 ». Тексты оферты,
* политики и возврата состоят из ссылок на законы, и по форме они те же самые:
*
* Закон РФ от 07.02.1992 2300-1 «О защите прав потребителей»
* Федеральный закон от 27.07.2006 152-ФЗ
* ст. 18.1 152-ФЗ
*
* Отличить номер закона от замера рынка регулярным выражением нельзя, а
* подгонять под них исключения по одному значило бы дырявить сам сканер.
* Правило, которое гейт защищает, к этим страницам и не относится: там числа
* ЦИТИРУЮТСЯ из нормативных актов, а не утверждаются о рынке.
*
* Список ЯВНЫЙ и короткий: молчаливое расширение (например «все страницы с
* длинным текстом») однажды укроет и маркетинговую.
*/
const LEGAL_PAGES = new Set(["oferta", "privacy", "refund", "docs"]);
const PAGE_DIRS = readdirSync(ROOT, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
e.name !== "__tests__" &&
e.name !== "_components" &&
!LEGAL_PAGES.has(e.name),
)
.map((e) => e.name)
.sort();
const SUBPAGES = PAGE_DIRS.flatMap((dir) =>
readdirSync(join(ROOT, dir))
.filter((name) => /^page\.tsx?$/u.test(name))
.map((name) => `${dir}/${name}`),
);
const V3_SOURCES = [
LANDING_ROOT,
...SUBPAGES,
...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
.sort()

View file

@ -13,6 +13,7 @@ import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import { BACKTEST } from "../landing-facts";
import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
@ -77,9 +78,9 @@ const SHOWCASE: ShowcaseResponse = {
describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText("14,5 %")).toBeTruthy();
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
expect(screen.getByText("325 из 327")).toBeTruthy();
expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
@ -92,7 +93,7 @@ describe("витрина лэндинга v3 без данных", () => {
expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
expect(screen.getByText("14,5 %")).toBeTruthy();
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
});
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {

View file

@ -0,0 +1,92 @@
/**
* Липкая панель не должна лежать поверх первого экрана.
*
* Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную
* цену вашей квартиры». `.barSpacer` это не лечит и не может: он добавляет
* высоту в КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние
* ~67px вьюпорта при любой прокрутке, включая самый верх страницы.
*
* Проверяется ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
* пересечение и проверяется, что панель убралась, затем обратное.
*/
import { render, screen } from "@testing-library/react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
let срабатывание: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
beforeEach(() => {
срабатывание = null;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
срабатывание = cb;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* Корень панели ищем ПО КОНТЕЙНЕРУ, а не через getByRole: когда панель убрана,
* на ней стоит aria-hidden, и роль «ссылка» из дерева доступности пропадает
* это и есть желаемое поведение, но локатор по роли на нём слепнет.
*/
function корень(c: HTMLElement): HTMLElement {
const link = c.querySelector('a[href*="estimate"]');
if (!link?.parentElement) throw new Error("панель не отрендерилась");
return link.parentElement;
}
const скрыта = (c: HTMLElement) => /barHidden/u.test(корень(c).className);
describe("липкая панель против первого экрана", () => {
it("без секции героя панель остаётся видимой, а не пропадает", () => {
// Наблюдать нечего — поведение обязано остаться сегодняшним.
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container)).toBe(false);
});
it("герой в кадре — панель убрана и недоступна с клавиатуры", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container), "до срабатывания панель видима").toBe(false);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
expect(корень(container).getAttribute("aria-hidden")).toBe("true");
// Убранная панель обязана уйти и из дерева доступности — иначе Tab
// уводит в кнопку, которой на экране нет.
expect(screen.queryByRole("link", { name: /Проверить квартиру/u })).toBeNull();
hero.remove();
});
it("герой ушёл — панель возвращается", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
act(() => срабатывание!([{ isIntersecting: false }]));
expect(скрыта(container), "панель не вернулась — призыв потерян навсегда").toBe(false);
expect(screen.getByRole("link", { name: /Проверить квартиру/u })).toBeTruthy();
hero.remove();
});
});

View file

@ -1,4 +1,3 @@
import { B2B_URL } from "../content";
import styles from "../landing-v3.module.css";
import { PublicLink } from "./PublicLink";
@ -57,9 +56,9 @@ export function InnerHeader() {
</PublicLink>
<div className={styles.hdrRight}>
<a href={B2B_URL} className={styles.hdrBizLink}>
<PublicLink to="business" className={styles.hdrBizLink}>
Для бизнеса
</a>
</PublicLink>
</div>
</div>
</header>

View file

@ -114,9 +114,14 @@ export function AccuracyV3({
});
}
// Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается
// отдельной плиткой, но её оговорка сюда не попадала — то есть на экране
// стояло «400 из 400 — уверенность низкая» без единого слова о том, почему.
// Плитка без своей оговорки — ровно то, что этот блок и не должен делать.
const caveats = [
...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats,
...BACKTEST.confidenceLow.caveats,
];
const deals = showcase?.deals ?? [];

View file

@ -21,16 +21,20 @@
* а добавляет одну точку входа с тарифами и порядком возврата.
*
* ССЫЛКИ КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate`
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг,
* то есть пункт не работал. Все переходы обычным `<a>` через `PublicLink`,
* потому что `next/link` подставляет basePath (см. его шапку).
* отдельный экран проверки, а не на якорь формы в герое. Все переходы
* обычным `<a>` через `PublicLink`, потому что `next/link` подставляет
* basePath (см. его шапку).
*
* «МЕРА ДЛЯ БИЗНЕСА» ВЕДЁТ НА `/business`, А НЕ НА `B2B_URL` (31.08.2026).
* Абсолютный адрес B2B-контура заканчивался формой входа `/trade-in/login`
* посетителю публичного сайта предлагали залогиниться в аккаунт, которого у
* него нет. Теперь пункт ведёт на публичную страницу связи, а ссылка на вход
* живёт внутри неё, второстепенной.
*/
import { safeUrl } from "@/lib/safeUrl";
import {
B2B_URL,
LEGAL_ENTITY,
REGION_NAME,
SUPPORT_EMAIL,
@ -70,9 +74,9 @@ export function FooterV3() {
<PublicLink to="articles" className={styles.ftrLink}>
Статьи
</PublicLink>
<a href={B2B_URL} className={styles.ftrLink}>
<PublicLink to="business" className={styles.ftrLink}>
МЕРА для бизнеса
</a>
</PublicLink>
</div>
</div>

View file

@ -49,7 +49,6 @@
* был `CityPicker`).
*/
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
@ -87,9 +86,9 @@ export function HeaderV3() {
</nav>
<div className={styles.hdrRight}>
<a href={B2B_URL} className={styles.hdrBizLink}>
<PublicLink to="business" className={styles.hdrBizLink}>
Для бизнеса
</a>
</PublicLink>
<a href="#check" className={styles.hdrCta}>
Проверить квартиру
</a>

View file

@ -1,3 +1,5 @@
"use client";
/**
* StickyCtaV3 липкая нижняя CTA-панель (макет ~ строки 518-521).
*
@ -32,17 +34,64 @@
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
* срока (`_estimate_days_on_market`, см. HeroV3).
*
* ПАНЕЛЬ ПРЯЧЕТСЯ, ПОКА ГЕРОЙ В КАДРЕ (31.08.2026). Владелец увидел на первом
* экране, что полоса лежит поверх абзаца «Мы называем реальную цену вашей
* квартиры». `.barSpacer` это НЕ лечит и не может: он добавляет высоту в
* КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние ~67px
* вьюпорта при ЛЮБОЙ прокрутке, включая `scrollTop = 0`.
*
* Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки,
* и панель дублирует призыв, закрывая текст. Уходит герой панель появляется.
*
* ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА, и это важно. Скрытие включает только клиентский
* наблюдатель; если скрипт не выполнился (ошибка, старый браузер, отключённый
* JS), поведение остаётся ровно сегодняшним полоса на месте. Обратный
* порядок (по умолчанию скрыта, показывает скрипт) в тех же условиях убрал бы
* призыв со страницы совсем, то есть чинил бы вид ценой работы.
*
* Наблюдатель смотрит на `#check` секцию героя (`HeroV3`). Если её на
* странице нет (компонент вставили в другую композицию), наблюдать нечего и
* панель просто остаётся видимой: снова сегодняшнее поведение, а не пустой
* экран.
*/
import { useEffect, useState } from "react";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
/** Секция героя: пока она в кадре, панель молчит. */
const HERO_ID = "check";
export function StickyCtaV3() {
const [heroVisible, setHeroVisible] = useState(false);
useEffect(() => {
const hero = document.getElementById(HERO_ID);
if (!hero || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setHeroVisible(entry.isIntersecting),
// Панель высотой ~67px снизу: считаем героя «в кадре», пока он заходит
// хотя бы на эту полосу — иначе она успевала бы мигнуть на его хвосте.
{ rootMargin: "0px 0px -68px 0px" },
);
io.observe(hero);
return () => io.disconnect();
}, []);
return (
<>
<div className={styles.barSpacer} aria-hidden="true" />
<div className={styles.barRoot}>
<div
className={`${styles.barRoot} ${heroVisible ? styles.barHidden : ""}`}
// Пока панель убрана, её не должно быть и для скринридера с
// клавиатуры: иначе Tab уходит в кнопку, которой на экране нет.
aria-hidden={heroVisible || undefined}
inert={heroVisible || undefined}
>
<p className={styles.barText}>
Узнайте цену своей квартиры и что происходит с похожими рядом
</p>

View file

@ -0,0 +1,155 @@
import type { Metadata } from "next";
import { safeUrl } from "@/lib/safeUrl";
import { InnerHeader } from "../_components/InnerHeader";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import {
B2B_URL,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing-v3.module.css";
/**
* «МЕРА для бизнеса» страница связи, а не вход.
*
* ЗАЧЕМ ЗАВЕДЕНА. Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале
* вели на `B2B_URL`. Реальная цепочка на проде: 308 `/trade-in` 307
* `/trade-in/v2` клиентский редирект `/trade-in/login`. Посетитель
* публичного сайта упирался в форму «Логин / Пароль» от аккаунта, которого у
* него нет и завести который на этом экране нельзя. Владелец просил починить
* это дважды.
*
* ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Ни цен, ни сроков ответа, ни объёмов, ни числа
* клиентов: таких величин никто не мерил, а на странице они читались бы как
* обязательство (то же решение, что в `docs/page.tsx` про часы поддержки).
* Формы заявки тоже нет принимать её в продукте нечем: анонимной ручки для
* B2B-лида не существует, и форма, которая никуда не пишет, хуже честной
* ссылки на живой канал. Каналы взяты из `content.ts` те же, что в подвале и
* в документах, а не заведены для этой страницы отдельно.
*
* ССЫЛКА НА ВХОД ОСТАВЛЕНА, но второстепенной: у кого доступ уже есть, тот
* пришёл именно за ней, и убрать её значило бы чинить один дефект вторым.
*/
export const metadata: Metadata = {
title: "МЕРА для бизнеса — связаться",
// Тот же noindex, что у остального публичного дерева (см. layout.tsx и
// docs/page.tsx): страница открыта по адресу, но в выдачу не идёт.
robots: { index: false, follow: false },
};
export default function BusinessPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<span aria-current="page">Для бизнеса</span>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>ДЛЯ БИЗНЕСА</div>
<h1 className={styles.artPageTitle}>МЕРА для бизнеса</h1>
<p className={styles.artPageLead}>
Оценка квартир по живым объявлениям под своей учётной записью
для агентства, застройщика или компании, которая принимает квартиры
в зачёт.
</p>
</header>
<section className={styles.docsSection} aria-labelledby="biz-who">
<h2 id="biz-who" className={styles.docsH2}>
Кому это нужно
</h2>
<p className={styles.artP}>
Тем, кто считает не одну квартиру, а много и регулярно: агентствам
недвижимости, отделам продаж застройщиков, компаниям с программами
трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная
проверка на сайте, но под учётными записями вашей команды, с
сохранением истории и месячным лимитом на аккаунт.
</p>
<p className={styles.artP}>
Если задача разовая квартира одна, и нужно понять цену, идти
сюда незачем: бесплатная проверка на сайте отвечает на этот вопрос
без договорённостей и без регистрации.
</p>
</section>
<section className={styles.docsSection} aria-labelledby="biz-diff">
<h2 id="biz-diff" className={styles.docsH2}>
Чем отличается от бесплатной проверки
</h2>
<div className={styles.docsLinks}>
<PublicLink to="estimate" className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Бесплатная проверка</span>
<span className={styles.docsLinkNote}>
Одна квартира, здесь и сейчас, без регистрации. Картина рынка
вокруг дома: сколько похожих квартир продаётся рядом и как давно
висят объявления
</span>
</PublicLink>
<span className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Бизнес-контур</span>
<span className={styles.docsLinkNote}>
Закрытый контур с доступом по учётной записи: история своих
расчётов, учётные записи команды, месячный лимит на аккаунт.
Доступ выдаётся вручную, после разговора, самостоятельной
регистрации в нём нет
</span>
</span>
</div>
<p className={styles.artP}>
Что именно войдёт в работу обсуждается: задача разная у
агентства, у застройщика и у трейд-ин программы. Поэтому на этой
странице нет ни тарифа, ни срока: назвать их до разговора значило бы
их придумать.
</p>
</section>
<section className={styles.docsSection} aria-labelledby="biz-contact">
<h2 id="biz-contact" className={styles.docsH2}>
Как связаться
</h2>
<p className={styles.artP}>
Напишите в любой из каналов коротко, что за компания и какая
задача: сколько адресов, как часто, что должно быть на выходе.
Этого хватает, чтобы ответить по существу с первого письма.
</p>
<div className={styles.artKv}>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>ПОЧТА</span>
<span className={styles.artKvVal}>
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
</span>
</div>
{telegramHref ? (
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>TELEGRAM</span>
<span className={styles.artKvVal}>
<a href={telegramHref} target="_blank" rel="noreferrer">
{SUPPORT_TELEGRAM_LABEL}
</a>
</span>
</div>
) : null}
</div>
<p className={styles.artFootnote}>
Уже работаете с нами? <a href={B2B_URL}>Вход в бизнес-контур</a>
по логину и паролю, которые вам выдали.
</p>
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -195,6 +195,10 @@ export const PUBLIC_ROUTES = {
articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru",
docs: "/docs",
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
// и заканчивалась формой входа для аккаунта, которого у посетителя нет.
business: "/business",
} as const;
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
@ -209,6 +213,12 @@ export const PUBLIC_ORIGIN = "https://meraocenka.ru";
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
* из текущего хоста было бы неверно.
*/
/**
* ЭТО ВХОД В ЗАКРЫТЫЙ КОНТУР, А НЕ ВИТРИНА. Цепочка редиректов приводит на
* `/trade-in/login` форму «Логин / Пароль». Человеку с улицы показывать её
* нельзя, поэтому шапка и подвал ведут на `PUBLIC_ROUTES.business`, а этот
* адрес остался ссылкой «у меня уже есть доступ» внутри той страницы.
*/
export const B2B_URL = "https://gendsgn.ru/trade-in/";
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */

View file

@ -42,8 +42,29 @@ export interface MeasuredValue {
readonly caveats: readonly string[];
}
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
/**
* Источник двух головных величин ПРЕДСТАВИТЕЛЬНЫЙ прогон 31.08.2026.
*
* ЧТО ПОМЕНЯЛОСЬ ПРОТИВ 29.08. Прежний прогон брал строки `ORDER BY id DESC`,
* то есть последние загруженные: 20 строк выборки приходились на ЧЕТЫРЕ улицы.
* Это не выборка города, а её кластер, и покрытие на нём выходило 88 %
* ВЫШЕ всего диапазона представительных пересборок (82,8-86,0 %). Значение
* было недостижимым, а не «чуть оптимистичным»; расхождение 14,5 % лежало
* чуть ниже диапазона 14,66-15,66 % тот же благоприятный край, но на
* порядок слабее. Здесь стоит МЕДИАНА трёх пересборок с разными солями.
*/
const BACKTEST_SOURCE =
"Бэктест на боевой базе (31.08.2026, engine=full): прогноз МЕРЫ против цены " +
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
"адресам, три пересборки с разными солями), медиана трёх прогонов";
/**
* Источник `confidenceLow` ПРЕЖНИЙ прогон, он не пересчитывался 31.08.
* Держится отдельной строкой, чтобы дата и выборка не выглядели общими с
* головными числами: там 325 сделок представительной выборки, здесь 327
* кластеризованных.
*/
const BACKTEST_CONFIDENCE_SOURCE =
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
@ -54,18 +75,21 @@ const BACKTEST_SOURCE =
*/
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
priceError: {
text: "14,5 %",
sampleN: 327,
text: "15,3 %",
sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
"Величина разового замера гуляет при пересборке выборки: на трёх пересборках " +
"расхождение вышло 14,7-15,7 %, попадание в коридор — 82,8-86,0 %. " +
"Точечное значение без этого диапазона обещает точность, которой у замера нет.",
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
],
},
coverage: {
text: "88 %",
sampleN: 327,
text: "84,3 %",
sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
@ -73,10 +97,21 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
],
},
confidenceLow: {
text: "325 из 327",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
// общей подписью «замер 31.08» это приписывало старому числу новую дату.
// Знаменатель 400 — это ЗАПРОШЕННЫЕ сделки, а не 325 сматчившихся:
// уверенность считается на каждой оценке, включая те, что не дошли до
// сверки с ценой ДКП.
text: "400 из 400",
sampleN: 400,
source: BACKTEST_CONFIDENCE_SOURCE,
caveats: [
"«Уверенность высокая» за три пересборки по 400 оценок встретилась один " +
"раз, «средняя» — 0, 6 и 9 раз. Это не скромность: порог требует " +
"разброса цен среди сопоставимых объявлений не выше 24 %, а на замере " +
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
],
},
};
@ -102,20 +137,47 @@ export const BACKTEST_CORRIDOR_LABEL = "±37 %";
* 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще
* позволяют назвать; «сделки за апрель» было бы вторым враньём.
*/
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
export const BACKTEST_PERIOD_LABEL =
"сделки за четыре квартала — с III квартала 2025 по II квартал 2026";
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` 5 954.
* КВАРТАЛЬНЫЙ СОСТАВ ПРЕДСТАВИТЕЛЬНОЙ ВЫБОРКИ ЗАМЕРЕН 31.08.2026.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее 1,3 % вместо
* 5,5 %, но это ровно та же ошибка, что чинится выше: доля от окна, которое
* замер не покрывал.
* Здесь стояла оговорка «никто не смотрел» с критерием снятия: посчитать
* `deal_date` по сделкам выборки. Посчитано, и ответ поменял подпись.
*
* Прогон 29.08 (`ORDER BY id DESC`) брал последние ЗАГРУЖЕННЫЕ строки, и у
* всех 327 сделок стояло `deal_date = 2026-04-01` один квартал. Пересборка
* вразброс покрывает ВСЁ окно `--since`, и её 400 сделок легли так:
*
* III кв 2025 106 (26,5 %) I кв 2026 88 (22,0 %)
* IV кв 2025 110 (27,5 %) II кв 2026 96 (24,0 %)
*
* То есть II квартал 2026 лишь четверть выборки. Прежняя подпись «сделки
* II квартала 2026 года» для этих чисел была бы прямым враньём, а знаменатель
* 5 954 (только II квартал) завышал бы долю вчетверо.
*
* Поэтому подпись называет окно целиком, а знаменатель взят того же окна.
*/
export const BACKTEST_POPULATION = 5_954;
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 325 сделок» не
* читалось как «столько их и было».
*
* Замер 31.08.2026 на проде, ровно те же фильтры, что у выборки бэктеста
* (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms, area > 0,
* `deal_date >= 2025-06-01`, город Екатеринбург): **24 333** сделки.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, ЧТО И ВЫБОРКА правило не изменилось,
* изменилось окно. Раньше здесь стояло 5 954 (только II квартал 2026), и это
* было верно для прогона 29.08, который в один квартал и укладывался. Для
* пересборок вразброс, покрывающих четыре квартала, тот же знаменатель дал бы
* долю вчетверо больше реальной 5,5 % вместо 1,3 %.
*
* Обе величины проверены одним запросом: в окне 24 333, из них с
* `deal_date = 2026-04-01` 5 954.
*/
export const BACKTEST_POPULATION = 24_333;
/**
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
@ -134,7 +196,7 @@ export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
* задачей.
*/
export const BACKTEST_MEASURED_ON = "2026-08-29";
export const BACKTEST_MEASURED_ON = "2026-08-31";
/** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100;

View file

@ -365,6 +365,24 @@
страницы при докрутке до конца (реквизиты в подвале, последний вопрос
FAQ). Высота одна на все ширины: панель везде в одну строку на узком
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
/* Панель убрана, пока герой в кадре (см. докстринг StickyCtaV3).
Убирается ТРАНСФОРМОМ, а не display:none: transform идёт на композиторе и
не вызывает перекладку. Страница и так дорога в отрисовке на мобильном
(1824 мс в Style & Layout), и лечение перекрытия не должно стоить ещё
одного пересчёта макета при каждом пересечении границы героя. */
.barHidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 180ms ease, opacity 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.barHidden {
transition: none;
}
}
.barSpacer {
height: 68px;
}
@ -1015,9 +1033,14 @@
text-wrap: pretty;
}
/* Плиток бывает четыре или пять (пятая тёмная, приходит из /stats и может
не прийти). Грид с фиксированной minmax-колонкой ронял пятую на вторую
строку одну и узкую читалось как обрыв. Flex-wrap с общим базисом делает
строку цельной при любом числе плиток: что помещается стоит в ряд,
остаток растягивается на всю ширину, а не висит слева огрызком. */
.accKpiGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
display: flex;
flex-wrap: wrap;
gap: 20px;
margin: 0 0 32px;
padding: 0;
@ -1025,6 +1048,8 @@
}
.accKpiTile {
flex: 1 1 200px;
min-width: 0;
background: var(--b2c-page-bg);
border: 1px solid var(--b2c-line);
border-radius: 10px;
@ -1132,10 +1157,14 @@
color: var(--b2c-ink);
}
/* Правый край общий с заголовком столбца (.accTableHeadCellRight);
иначе значения стоят левее шапки и столбец читается пустым.
На мобильном брейке ниже text-align сбрасывается в left. */
.accCellErr {
font-family: var(--b2c-font-mono);
font-size: 12px;
color: var(--b2c-muted);
text-align: right;
}
.accCellGain {

View file

@ -103,6 +103,7 @@ const PUBLIC_SHORT_PATHS = [
"/articles",
"/articles/kak-ocenit-kvartiru",
"/docs",
"/business",
];
export function isPublicPath(rawPath: string): boolean {