Skip to content

feat(web): пересборка фронта на дизайн-системе и кабинет госагентства - #1

Merged
spelingbee merged 21 commits into
mainfrom
feat/web-redesign
Aug 10, 2026
Merged

feat(web): пересборка фронта на дизайн-системе и кабинет госагентства#1
spelingbee merged 21 commits into
mainfrom
feat/web-redesign

Conversation

@spelingbee

Copy link
Copy Markdown
Owner

Фронт apps/web пересобран с нуля на дизайн-системе. Добавлены два экрана для госагентства. apps/api не менялся, кроме одного починочного коммита — см. ниже.

Что внутри

Экраны владельца — «Сегодня», «Занятость» (День / Ночи / Сетка), карточка брони, «Очередь», вход, создание брони. Ставка на телефон 360×780 и на то, что владелец конкурирует не с другой системой, а с тетрадью.

Экраны агентства/gov/occupancy и /gov/issyk-kul. Построены вокруг разрыва: сначала опубликованные цифры Нацстаткома с источником под каждой, затем перечень того, чего нет ни в одном отчёте страны (номерной фонд, предложение, проданные номеро-сутки и, как следствие, загрузка), и только потом — что даёт система. Синтетические данные подписаны и стоят строго в третьей части, чтобы полоса «синтетические» не оказалась над чужой официальной статистикой.

Дизайн-системаapps/web/design-system/: tokens.css как единственный источник правды, Tailwind-пресет, девять примитивов. В экранах нет ни одного литерала цвета, кегля, отступа, радиуса и длительности.

Ключевые решения

  • Полуоткрытый интервал [checkIn, checkOut) проведён через все три режима «Занятости». Выезд и заезд в один день — не конфликт, и это написано словами, а не значком: именно здесь ошибается бумажная тетрадь.
  • Иерархия действий по цене ошибки. Ровно одна залитая кнопка на экран, и она никогда не создание сущности и не выселение. Необратимое — красный контур, расхождение с физическим миром — приглушённый контур с подтверждением.
  • Офлайн-слой расширен аддитивно. composables/useApi.ts и offline/db.ts переписаны не были: схема Dexie получила версию 2 поверх нетронутой первой, flushQueue — поблокировочную остановку по брони и разбор статусов вместо «всё подряд конфликт». Dexie вынесен из входного чанка в ленивый импорт.
  • Числа на гос-экранах не хранятся готовыми. Загрузка пересчитывается из двух соседних столбцов, три разреза одной совокупности сверяются между собой в рантайме, результат сверки печатается на экране. Разъедется выгрузка — крест появится здесь, а не через квартал в чужом отчёте.

Проверено

pnpm typecheck api + web, чисто
pnpm build обе сборки проходят
pnpm --filter @nomadcore/api test 24 / 24
test:races на живом PostgreSQL 2 / 2 — анти-овербукинг под конкуренцией подтверждён впервые
Приёмка экранов 36 вариантов: 360×780, светлая и тёмная тема, русский и кыргызский
Контраст основной текст ≥ 7:1, вторичный ≥ 4.5:1 против худшего фона своей темы
Прогон сценария показа 30 / 30 утверждений docs/DEMO-SCRIPT.md сходятся с живым приложением

Что осталось и требует решения

  • apps/api тронут одним коммитом (fix(api): починить typecheck, build и test:races). Это не часть пересборки фронта: TS4094 и падение races-теста существовали до ветки, что подтверждается пустым git diff main..HEAD -- apps/api на момент их обнаружения. Правка сделана после отдельного разрешения.
  • test:races при отсутствии DATABASE_URL пропускается молча и в CI покажет зелёный, ничего не проверив. Ни build, ни test:races в ci.yml сегодня не подключены.
  • Вес критического пути 116.9 КБ gzip при цели 100. Единственный оставшийся рычаг — отказ от @nuxtjs/i18n, который решено сохранить.
  • Дыры в API перечислены в docs/WEB-API-GAPS.md: нет GET/PATCH /api/bookings/:id, четырёх полей брони, конфликт версий приходит как 500 вместо 409 с телом, идемпотентности нет. Экраны, которые в них упираются, показывают заглушку с явной пометкой, а не выдуманные данные.
  • "start": "node dist/main.js" в apps/api неверен — сборка кладёт dist/src/main.js.
  • Кыргызские названия месяцев оставлены в гражданской форме («7-август»): традиционные не помещаются в 360px. Нужен носитель языка, правится только i18n/locales/ky.json.

🤖 Generated with Claude Code

spelingbee and others added 21 commits August 7, 2026 17:33
design-system/ — токены, Tailwind-пресет и 9 примитивов, единственный
источник правды по цвету, кеглю, отступам и движению.
nuxt/ — референс поведения четырёх экранов владельца плюс API-CONFLICTS.md,
постановка задачи на бэкенд по разрешению конфликтов.

Референс не портируется как есть: страницы написаны под другой словарь
Tailwind, чем поставляемый пресет. Держим его как описание поведения.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Удалены: app.vue (в нём жили все ~230 строк глобального CSS — вся прежняя
дизайн-система), layouts/default.vue, pages/{index,calendar,guests,settings,rooms}.vue.

Сохранены сверх исходного плана и почему:
- composables/useSession.ts — useApi.ts импортирует его напрямую, удаление
  сломало бы файл, который переписывать запрещено;
- plugins/sync.client.ts — единственный вызывающий flushQueue(); без него
  очередь мутаций перестаёт сливаться вообще;
- pages/login.vue и middleware/auth.global.ts — по решению владельца вход
  сохраняется и переверстывается по токенам.

nuxt.config.ts не тронут: правки идут отдельным диффом в шаге 3, когда
дизайн-система окажется на месте и ссылки в конфиге станут валидными.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
design-system/ перенесена в apps/web. tokens.css подключён глобально и
идёт ПЕРЕД утилитами Tailwind; пресет ncPreset подключён через
tailwind.config.ts; девять примитивов зарегистрированы глобально без
префикса пути и пишутся как <NcButton>.

Заведено шесть токенов вместо литералов, которые уже стояли в примитивах:
  --nc-stroke-hair/control/accent  1/2/4px — линии, рамки, акцентные полосы
  --nc-btn-min-w 114px, --nc-lh-tight 1.15, --nc-icon-stroke 2.2
  --nc-scrim — затемнение под шторкой, у тёмной темы своё значение
Значения не выдуманы: это те же числа, что стояли в NcButton, NcRow,
NcBanner, NcSegmented, NcField, NcIcon и NcSheet.

Три починки в самой дизайн-системе:
1. tailwind-preset.ts экспортировал ОДИН объект и именованно, и по
   умолчанию. Интероп jiti дописывает именованные экспорты в default, и
   получалось ncPreset.ncPreset === ncPreset — слияние пресетов Tailwind
   уходило в бесконечную рекурсию, сборка падала с RangeError в vite:css.
   Экспорт по умолчанию убран.
2. Блок @media (prefers-color-scheme: dark) не переопределял
   --nc-shadow-sheet и --nc-focus-ring: пользователь системной тёмной темы
   получал светлую тень и светлое кольцо фокуса. Добавлены.
3. NcSheet затемнял фон литералом rgba(17,19,16,.5) — в тёмной теме он
   почти не виден на #16181A. Теперь токен, у тёмной темы свой.

Добавлен apps/web/tsconfig.json: его не было вовсе, из-за чего
`nuxt typecheck` падал с «Cannot find matching tsconfig.json», то есть
шаг typecheck из CI для apps/web не отрабатывал ни разу. Включены strict,
noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch.

В nuxt.config.ts НЕ тронуты: ssr:false, runtimeConfig.apiBase, весь блок
i18n (ru/ky/en, no_prefix, кука nomadcore_locale), pwa registerType,
workbox navigateFallback и NetworkFirst на /api/*, иконки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Отвечает на «что делать сейчас» без прокрутки: счётчик из трёх плиток
(Заезды/Выезды/Ждут) укладывается в первые 204px и заодно фильтрует
список. Порядок без фильтра — по срочности: решение, выезд, заезд.

Иерархия действий по цене ошибки:
- ровно одна залитая кнопка на экране — «Подтвердить · имя» внизу,
  и только когда есть чего подтверждать;
- в строках заливки нет вообще: их шесть, «главной из шести» не бывает;
- «Выселить» — приглушённый контур (careful) плюс подтверждение в шторке:
  статус разойдётся с физическим миром, гость останется в номере;
- выселение не залито и на своей шторке — README, раздел про цену ошибки.

Статус нигде не передан одним цветом: слово в плашке, глиф перед именем
(?  →  ●  ✓) и цветной маркер слева от строки — три канала.

Слой данных: GET /api/bookings с окном, расширенным на сутки назад.
Серверный фильтр checkIn < to && checkOut > from МОЛЧА выбрасывает брони
с checkOut = сегодня, то есть ровно сегодняшние выезды. Обход помечен
комментарием в useBookings.ts.

Автопроверка приёмки на 360×780 (scratchpad/shots.mjs), все 4 варианта
светлая/тёмная × ru/ky: залитых кнопок 1, главное действие на 647px из
780 (нижняя треть), тач-таргетов меньше 44px нет, кегля ниже 13px нет,
текста ниже порога контраста нет, горизонтального переполнения нет.
Тёмная тема проверена ОБОИМИ каналами: prefers-color-scheme и
data-nc-theme. Снимки — docs/web-proof/.

Осознанно НЕ реализовано, потому что в схеме нет полей:
блок «Документы» и шторка «Заселить без документов?» — у Booking и Guest
нет признака предъявленного документа. Также нет кода брони, гражданства
и числа гостей, которые печатает прототип. Подробности — в отчёте.

Значок «+ Новая бронь» в шапке пока не выведен: поток создания брони —
пятая поверхность, в четыре экрана не входит.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Порядок и умолчание взяты из замера на 360px (STAGE-2, п.1), а не из
привычки: День отвечает на «что у меня сегодня» за 0 тапов, Ночи забрали
у сетки её работу «есть ли места на выходные» (1 тап, одна рука), Сетка
осталась третьей — единственный ответ на «куда переселить», но требует
горизонтального скролла и двух рук.

Полуинтервал [checkIn, checkOut) читается во всех трёх режимах:
- День  — словами: «Ким Мария → Chen Wei», плашка «Стык» и пояснение
          «выезд и заезд в один день — номер свободен для нового гостя»;
- Ночи  — по построению: строка и есть ночь, у неё ровно один постоялец,
          «наложения» не существует и объяснять нечего;
- Сетка — геометрией: полоса идёт от середины дня заезда до середины дня
          выезда, поэтому Ким и Chen 7-го в номере 3 соприкасаются
          с зазором, а не перекрываются. Видно на снимке.

Залитых кнопок на экране НОЛЬ во всех трёх режимах — экран отвечает на
вопрос, а не требует действия. Переключатель режима — состояние, а не
действие: фон band, жирное начертание и подчёркивание, три канала.

Занятость считается ИЗ БРОНЕЙ, а не из GET /api/availability. Причины
в комментарии useAvailability.ts: ответ разрежён (свободных дней в нём
нет), в ячейке нет checkIn/checkOut для полосы, и главное — он не знает
про локальные неотправленные изменения, а сетка обязана их показывать,
иначе владелец переселит гостя в занятый номер.

Две правки по итогам автопроверки:
1. «Занято всё» было --nc-action-danger-fg: 5,45:1 на signal-error-bg при
   пороге 7:1 для основного текста. Кнопочный токен на текст не годится —
   заменён на --nc-signal-error-fg (8,2:1).
2. Шаг сетки 44 → 48px. Полоса брони равна CELL × ночи − 4px зазора,
   поэтому при 44 бронь на одну ночь давала тап-цель 40px. При 48 она
   ровно 44px. Высота строки 44 → 56, иначе полоса выходила 36px.

Автопроверка: 12 вариантов (3 режима × светлая/тёмная × ru/ky) — залитых
кнопок 0, тач-таргетов меньше 44px нет, кегля ниже 13px нет, текста ниже
порога контраста нет, переполнения по горизонтали на 360px нет.

Снимки — docs/web-proof/occ-*.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Композиция прототипа целиком, недоступное — неактивным с подписью причины
(решение владельца). Заглушек, которые «как будто работают», нет.

Работает: имя гостя, тип номера, статусная плашка, четыре факта
(Номер / Заезд → выезд / Ночей / К оплате), статусная лента из четырёх
шагов, «Выселить» с подтверждением, «Отменить бронь» с подтверждением,
главное действие «Подтвердить» / «Заселить».

Неактивно, потому что нечем реализовать:
- блок «Документы» и кнопка «Отметить» — признака предъявленного
  документа нет ни у Booking, ни у Guest;
- «Продлить на 1 ночь» и «Ранний выезд сегодня» — PATCH /api/bookings/:id
  не существует, сервер умеет только смену статуса.
Под каждым блоком написано, что именно блокирует бэкенд.

Заливка: одна кнопка при HOLD и CONFIRMED, НОЛЬ при CHECKED_IN и
CHECKED_OUT — у гостя в доме экран ничего не требует. Выселение живёт во
второстепенных действиях приглушённым контуром и не залито никогда.

Одиночную бронь грузить нечем: GET /api/bookings/:id не существует,
карточка ищет её в уже загруженном диапазоне. Бронь за пределами
горизонта честно показывает «Бронь не найдена среди загруженных».

Decimal(10,2) форматируется СТРОКОЙ (utils/money.ts): через Number
теряется точность, а в экране конфликта суммы придётся сверять посимвольно.

Добавлен docs/WEB-API-GAPS.md — полный список того, чего фронту не хватает
в API, с номерами строк и предлагаемым минимумом.

Автопроверка, 8 вариантов (HOLD и CHECKED_IN × светлая/тёмная × ru/ky):
залитых кнопок 1 и 0 соответственно, тач-таргетов меньше 44px нет, кегля
ниже 13px нет, переполнения на 360px нет.

ОДНО замечание по контрасту, и оно на уровне дизайн-системы, а не кода:
«Отменить бронь» — --nc-action-danger-fg #B03024 на --nc-bg-canvas даёт
6,37:1 при пороге 7:1 для основного текста. README дизайн-системы сам
указывает для этого токена 6,4:1 (замерено на surface). Токена «красный
текст ≥ 7:1» в системе нет. Число не выдумывал — вынес в отчёт.
В тёмной теме порог проходится.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Офлайн-слой (согласованный дифф, только добавления):
- offline/db.ts — версия 2. Схема v1 не тронута, таблицы и индексы те же;
  добавлены НЕОБЯЗАТЕЛЬНЫЕ поля state, bookingId, baseVersion, attempts,
  error, server и индексы по state и bookingId. Апгрейд лишь проставляет
  умолчания старым строкам, данные переносятся как есть.
- useApi.ts — flushQueue разбирает код ответа. Прежнее поведение сохранено
  байт в байт для всего, что НЕ 409/422/423: мутация уходит в conflicts и
  удаляется. Успешный путь и break по обрыву сети не тронуты.

Раскладка кодов сделана по РЕАЛЬНОМУ контракту, а не по ожидаемому:
  409 + body.code      -> rejected (OVERBOOKING, INVALID_TRANSITION)
  409 + body.booking   -> conflict (задел; сегодня API тела 409 не отдаёт)
  422 / 423            -> rejected (задел; сегодня не возвращаются)
  прочее, включая 500  -> как раньше
Конфликт версий сервер отдаёт пятисоткой, поэтому состояние conflict
сегодня недостижимо. Ветка оживёт без правок клиента.

Конфликтная мутация блокирует ТОЛЬКО свою бронь (по bookingId), изменения
по другим броням продолжают уходить — иначе один спорный гость
останавливает весь дом. Правило из nuxt/API-CONFLICTS.md.

Одно изменение поведения, и оно согласовано: при 409/422 мутация больше
не удаляется из очереди, а остаётся с пометкой. Как следствие syncPending
теперь считает и заблокированные — это и нужно, счётчик на вкладке должен
оставаться красным, пока решение не принято.

Экран: заголовок, три счётчика, карточки мутаций. Залитая кнопка одна и
она ГЛОБАЛЬНАЯ — «Попробовать отправить» внизу; в карточках её нет,
дублирование давало N+1 залитых кнопок у самого безопасного действия.
Отказ сервера объяснён словами, а не кодом. «Переселить в другой номер»
не выведено: PATCH /api/bookings/:id не существует, причина написана.

Демо-очередь — в fixtures/demo.ts, две строки. Состояния conflict в демо
НЕТ намеренно: показать его значило бы пообещать сценарий, которого в
продукте нет.

Регрессия по всем экранам: 24 варианта (4 экрана, 3 режима «Занятости»,
светлая/тёмная, ru/ky). Тач-таргетов меньше 44px нет, кегля ниже 13px
нет, переполнения на 360px нет. Залитых кнопок: Сегодня 1, Занятость 0,
Карточка 1/0 по статусу, Очередь 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Поведение не менялось: те же два шага и те же два запроса, POST /auth/otp
затем POST /auth/verify, setToken и переход на «/». Раньше экран держался
на переменных из app.vue (--brand, --muted, --checkedin-bg), которого
больше нет, и после его удаления рисовался без стилей.

Оболочки нет намеренно: полоса связи и нижняя навигация показывают
состояние объекта, а объекта до входа ещё нет.

Два пропа добавлены в примитивы, оба аддитивные:
- NcField получил type/inputmode/autocomplete/required. Без них примитив
  не мог сделать ни поле телефона, ни поле кода из SMS, и вёрстка ушла бы
  мимо системы.
- NcButton получил type. Раньше он жёстко ставил type=button, поэтому
  единственная кнопка системы не могла отправить форму, а Enter в поле
  ввода не срабатывал нигде.

Автопроверка, 4 варианта: залитая кнопка одна, тач-таргетов меньше 44px
нет, кегля ниже 13px нет, текста ниже порога контраста нет, переполнения
на 360px нет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Плагин синхронизации затирал счётчик демо-очереди нулём из настоящего
(пустого в демо) Dexie, и полоса связи писала «Всё отправлено» поверх
двух неотправленных мутаций. Гард на демо-режим в sync.client.ts и
useSyncState.refresh(); отслеживание online при этом сохранено.

Пометки sync на бронях согласованы с демо-очередью в одном месте
(fixtures/demo.ts): офлайн — это слой из четырёх точек присутствия,
а не уведомление, и четвёртая из них — пометка на КОНКРЕТНОЙ броне —
раньше в демо не показывалась.

Теперь видно всё сразу: полоса связи с числом, красный счётчик на
вкладке «Очередь», плашка «Не отправлено» в строке брони и сам экран.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Значок «+» 44×44 в шапке «Сегодня» и шторка создания. Заливки создание не
получает НИКОГДА: это «всегда», а не «сейчас», и последствий у него ноль.

Контракт взят из apps/api/src/bookings/dto.ts, а не из прототипа. Гость
передаётся ИНЛАЙНОМ (guestName) — DTO это допускает, и отдельный
POST /api/guests не нужен. Это принципиально для офлайна: две мутации
подряд, из которых вторая зависит от id первой, очередь провести не
смогла бы. priceTotal и source не отправляются вовсе: сервер их принимает,
но игнорирует.

Офлайн: если сети нет, useApi кладёт мутацию в очередь и возвращает
{queued:true}; бронь показывается локально с пометкой «Не отправлено».
Экран не ждёт сервер. Идемпотентности у эндпоинта нет — повторная отправка
создаст дубль, отмечено в docs/WEB-API-GAPS.md.

Одно расхождение с прототипом, разрешено в пользу правила: прототип
заливает выбранный тип номера и выбранный день, то есть даёт на слое три
заливки. README дизайн-системы говорит, что переключатели — состояние, а
не действие, и заливку не берут; критерий приёмки требует ровно одну
заливку на слой. Селекторы сделаны состоянием (фон band, жирное
начертание, левая полоса — три канала), единственная заливка шторки
принадлежит «Создать».

Кнопка «Создать» НЕ блокируется заранее: заблокированная главная кнопка
оставляет слой без единственной заливки и молчит о причине. Проверка на
нажатии, причина названа словами.

Вместо голого «мест нет» показывается ближайшая дата, когда выбранный тип
свободен на то же число ночей: отказ без выхода бесполезен.

Аудит расширен: заливки в шторке считаются отдельным слоем, как и
предписывает README. Регрессия — 36 вариантов, «Сегодня» со шторкой даёт
фон=1 шторка=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Статический импорт ~/offline/db тянул Dexie (~32 КБ gzip) во входной чанк,
то есть в первую загрузку владельца на дешёвом Android — при том что до
первого чтения кэша или первой мутации он не нужен. Это же предписывает
nuxt/README.md.

Импорт значения заменён на ленивый динамический, запомненный в промисе на
уровне модуля; типы остались статическим import type и стираются при
сборке. Все обращения к базе и так жили внутри async-функций, поэтому
сигнатуры не изменились и поведение тоже — сдвинулся только момент
загрузки чанка.

Замер до/после, первая загрузка «/» с пустым кэшем:
  входной чанк      134,0 → 101,7 КБ gzip
  критический путь  ~145  → 116,9 КБ gzip
  Dexie             теперь отдельный чанк 31,9 КБ с rel=prefetch —
                    низкий приоритет, при Save-Data не грузится вовсе

Офлайн это не ломает: service worker прекэширует всю статику
(globPatterns в nuxt.config), после первой успешной загрузки чанк
доступен без сети.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…действия

Замер уточнил картину: --nc-action-danger-fg #B03024 даёт 6,37:1 на
surface, но 4,86:1 на худшем фоне светлой темы (--nc-bg-band). Для
ГРАНИЦЫ этого достаточно — порог нетекстового контраста 3:1. Для ПОДПИСИ
нет: критерий приёмки требует 7:1 для основного текста.

Одним значением две роли не закрыть, поэтому заведён отдельный токен.
Значение задано ссылкой на --nc-signal-error-fg, а не копией числа:
красный текст в системе один, новых литералов не появилось, но роли
теперь могут разойтись, не ломая друг друга.

Замер нового токена:
  светлая  10,37 surface · 8,84 canvas · 9,58 sunken · 7,90 band
  тёмная   10,41 surface · 10,97 canvas · 9,57 sunken · 8,79 band

NcButton в варианте danger берёт текст из нового токена, границу — из
прежнего. Затронуты «Отменить бронь» на карточке и «Отклонить» в очереди.

После правки автопроверка проходит ПОЛНОСТЬЮ: 36 вариантов, 0 нарушений
контраста (было 6 в светлой теме).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Три поломки, все существовали до пересборки фронта. Правки минимальные,
поведение сервера не менялось.

1. TS4094 в public.controller.ts — ломал и `pnpm typecheck`, и `nest build`.
   makeRateLimitGuard возвращал безымянный класс с приватным полем buckets;
   TypeScript не мог назвать этот тип в объявлении экспортируемых констант.
   Указан явный возвращаемый тип Type<CanActivate>. Рантайм не затронут,
   инкапсуляция сохранена.

2. test:races падал с ERR_MODULE_NOT_FOUND ещё до подключения к базе:
   динамические импорты шли без расширения .ts, а ESM-резолвер Node их не
   достраивает. Расширения добавлены.

3. После этого вскрылось следующее: --experimental-strip-types не умеет
   параметр-свойства конструктора (constructor(private readonly prisma)),
   на которых построен весь NestJS, а --experimental-transform-types
   спотыкается о декораторы. Раннер переведён на tsx, который уже был в
   devDependencies. Скрипт `test` не тронут: он гоняет Nest-независимые
   модули и на strip-types работает.

4. Утверждение в тесте «back-to-back брони проходят» ожидало HOLD, тогда
   как сервис намеренно создаёт владельческую бронь сразу CONFIRMED
   (HOLD приходит из Telegram-бота и с публичной витрины). Ожидание было
   списано со значения по умолчанию в схеме и устарело. Добавлена проверка
   даты заезда — того, ради чего тест и написан.

Проверено на живом PostgreSQL (docker compose up postgres):
  test:races  2/2 — инвариант ADR-1 «из двух одновременных броней проходит
              ровно одна» подтверждён на реальной базе ВПЕРВЫЕ;
              стык [checkIn, checkOut) тоже проходит
  test        24/24
  typecheck   apps/api + apps/web — оба Done
  build       apps/api + apps/web — оба успешны

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Найдено сквозной проверкой против ЖИВОГО API — на фикстурах этого было
не видно. Демо-номера называются «1» и «Ю1», реальные из prisma/seed —
«№101», «№201». Закреплённая колонка шириной --nc-space-48 (48px) их не
вмещала, и подпись наезжала на дни.

Заведён --nc-grid-label-w (76px): ширина рассчитана на шесть знаков
жирным --nc-fs-200. Добавлено усечение многоточием как страховка на
случай ярлыка длиннее расчётного — выпускать текст поверх сетки нельзя.
Ширина трека в шаблоне тоже считается от нового токена.

Проверено на живой базе: полоса «→ Тестов» идёт от середины 7-го до
середины 9-го, ярлыки не пересекают колонку дней. Регрессия демо-режима
на 360px — 36 вариантов, все чисты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Три вещи, на которых показ непрограммисту разваливался.

1. ФЛАГ NUXT_PUBLIC_SHOW_API_GAPS, по умолчанию ВЫКЛЮЧЕН.
   Блоки, которые нечем реализовать («Документы», «Продлить», «Ранний
   выезд», «Другой номер»), больше не рисуются вовсе. Раньше владелец
   видел серые кнопки с текстом «PATCH /api/bookings/:id не существует» —
   это надпись для разработчика внутри продукта, и читается она как
   поломка. Прятать их дизайн разрешает: когда экран ничего не требует,
   он ничего и не обещает.
   Режим разбора включается NUXT_PUBLIC_SHOW_API_GAPS=1 и работает как
   прежде. Список пробелов в обоих случаях живёт в docs/WEB-API-GAPS.md.

2. ДЕМО-ФИКСТУРЫ: пиковая ночь сдвинута с «завтра» на «послезавтра».
   Шторка создания открывается с умолчанием «сегодня, 2 ночи», то есть
   окном [0, +2). Пока полностью занята была ночь +1, это окно НИКОГДА
   не имело свободных номеров, и главный сценарий продукта с порога
   упирался в «Свободных номеров нет». Теперь окно по умолчанию находит
   номер 4, а пиковая ночь по-прежнему видна в «Ночах» и «Сетке».
   Счётчики «Сегодня» (3 заезда, 2 выезда, 1 ждёт) и стык в номере 3
   сохранены без изменений.

3. ФОРМАТ ДАТЫ ЛОКАЛИЗОВАН. Порядок и разделитель заданы шаблоном в
   словаре (common.datePattern), а не склейкой в коде: в кыргызском число
   пишется с порядковым дефисом, и зашитый пробел выдавал русскую форму
   на кыргызском экране. Теперь «7 авг» и «Пятница, 7 августа» против
   «7-авг» и «Жума, 7-август».
   ВНИМАНИЕ: взята гражданская форма названий месяцев — та, что
   используется в официальных документах и прессе КР и что стоит в
   прототипе. Традиционный календарь (Баш Оона и прочие) в короткую
   форму на 360px не помещается. Нужна проверка носителем; менять —
   только в locales, код трогать не придётся.

Автопроверка: 28 вариантов с выключенным флагом — все чисты; карточка
брони с гостем в доме даёт ноль залитых кнопок. Режим разбора проверен
отдельно и работает как прежде.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
По отзыву: «глаза расходятся, непонятно что куда нажимать». Разбор снимка
подтвердил шесть причин, и главная из них моя.

1. Я заменил ДАННЫЕ ОБЪЯСНЕНИЕМ. В прототипе строка несёт «4 авг → 7 авг ·
   ночь 2/3»; у меня вместо этого стояло предложение «выезд и заезд в один
   день — номер свободен для нового гостя», причём в каждой строке со
   стыком. Это был самый длинный текст на экране, и это сноска, а не
   информация. Перестарался с критерием «полуинтервалы читаются без
   легенды» и превратил строку списка в пояснение.
   Факты вернулись: «6 авг → 12 авг · ночь 3 из 6». Объяснение стыка
   теперь ОДИН раз под заголовком дня и только когда стык на экране есть.

2. Четыре разных левых края в одной строке — не за что зацепиться взглядом.
   Теперь две вертикали: слева номер, имя и факты; справа одно слово о том,
   что с номером сегодня. Взгляд идёт двумя колонками, а не зигзагом.

3. Статус говорился дважды: глиф плюс плашка, а у свободных «Свободно»
   стояло и строкой, и плашкой — слово дублировало само себя. Осталось:
   цвет на маркере слева, слово справа, глиф перед словом.

4. Ничего не выглядело нажимаемым, хотя строка открывает карточку.
   Добавлен шеврон — и его нет у свободных номеров, которые не открываются.

5. Влезало 8 номеров из 10 при том, что смысл режима — увидеть дом целиком.
   Строка ужата с ~78px до --nc-touch-action, помещается 9–10.

6. Стрелки перелистывания были двумя обведёнными квадратами и перетягивали
   взгляд раньше содержимого. Переведены в quiet.

Свободный номер теперь одна строка: «Свободно» приглушённым и под ним либо
тип номера, либо «выехал: имя».

Проверено: 4 варианта «Дня» и 16 вариантов остальных экранов чисты,
кыргызский держит две колонки без переполнения на 360px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Седьмая поверхность и единственная, адресованная не владельцу гостевого
дома. Ноутбук или проектор, шкала desk (от 12px), своя оболочка без полосы
связи и нижней навигации: это инструмент принятия решений, а не лента.

Экран построен вокруг одного обещания: КАЖДОЕ число пересчитывается
калькулятором из соседних столбцов, и три разреза одной совокупности
обязаны сойтись.

  Разрез 1 — по регионам, 9 строк, сортировка по любому столбцу
  Разрез 2 — по категориям классификации
  Разрез 3 — по месяцам, инлайновый SVG без библиотеки графиков
  Сверки   — считаются В РАНТАЙМЕ из тех же величин, что напечатаны выше,
             и выписаны словами с ✓ и обеими частями равенства

  объектов 1 284 · номеров 11 590 · предложение 1 803 380 н-сут.
  продано 378 200 · загрузка 378 200 ÷ 1 803 380 = 21,0 %

Производными являются загрузка, ADR, средняя продолжительность, динамика
к 2025 и дней в работе — ни одно из них не хранится готовым. Койко-сутки
вынесены отдельно от загрузки и подписаны как оценка: прямого учёта
спящих нет.

Синтетическая природа данных подписана полосой под шапкой, а не сноской.
Добавлен блок «Охват»: показатели занижены на величину неподключённого
фонда, и долю охвата надо называть вместе с цифрой — эта оговорка уйдёт
наверх вместе с числом.

НАЙДЕН И ПОЧИНЕН ТИХИЙ БАГ: класс .grid в scoped-стилях совпал с
одноимённой утилитой Tailwind display:grid и превратил таблицу в
CSS-сетку — таблица занимала половину ширины. Scoped-стили от этого не
защищают: коллизия происходит на свойстве, которое в компоненте не
объявлено, поэтому побеждает утилита. Отловлено замером getComputedStyle,
класс переименован в .tbl, весь код просканирован на такие же коллизии —
.grid был единственным.

fallbackLocale: ru вынесен в i18n/i18n.config.ts (это опция vue-i18n,
а не модуля). Закрывает две дыры: кабинет ведётся только по-русски,
и en.json новыми ключами не пополнялся — теперь недостающий ключ берётся
из русского, а не печатается путём вида «gov.col.occupancy».

Проверка: независимый пересчёт всех сумм вне приложения совпал с тем,
что отрисовано; 5 сверок из 5 проходят в светлой и тёмной теме; ключей
i18n не потеряно, кегля ниже 12px нет, переполнения нет, консоль чиста.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Восьмая поверхность. Отвечает не на «какая загрузка», а на «ГДЕ ДЕЛАТЬ
ПИЛОТ», поэтому первым содержательным столбцом идёт охват, а не продажи.

  подключено 612 · фонд (оценка) 1 003 · охват 61,0 % · разрыв 391

Охват 61,0 % совпадает с величиной, заявленной на общем экране, — это
не совпадение, а следствие: обе считаются из одних чисел.

Кандидат на пилот ВЫЧИСЛЯЕТСЯ, а не назначается: берётся район с
наибольшим числом неподключённых объектов, а не с низшим процентом.
40 % охвата при фонде в двадцать домов — это восемь домов работы, а не
пилот. Выбор — Тонский район (Боконбаево): 94 объекта из 168 вне системы,
охват 44,0 %, самый низкий по области при значимом фонде. Цель сезона
считается тут же: 47 объектов поднимут охват до 72,0 %.

Сверки идут ПРОТИВ ОБЩЕГО ЭКРАНА, а не против копии чисел: семь базовых
величин сравниваются со строкой «Иссык-Кульская область» из разреза по
регионам. Все семь сходятся построчно.

Фонд — единственная величина на экране, которую система не измеряет
(реестр классификации плюс полевой обход), и она подписана как оценка.
Все остальные показатели района считаются только по подключённым
объектам и поэтому занижены на величину разрыва — сказано в сноске.

ПОЧИНЕНО ПО ХОДУ: сноски были перенумерованы неверно. Я переиспользовал
заголовки колонок с общего экрана, и их надстрочные знаки столкнулись
с местными — «²» означало и «Охват», и «Предложение», «³» и «Разрыв»,
и «Загрузка». На экране, весь смысл которого в строгости, это
недопустимо. Экрану дана собственная сквозная нумерация ¹–⁷, а в
проверку добавлен автоматический контроль: каждый знак в заголовке
обязан иметь ровно одну сноску, нумерация обязана быть сквозной.

Проверка: независимый пересчёт всех семи величин вне приложения совпал
с отрисованным; 7 сверок из 7 в светлой и тёмной теме; сноски
[¹²³⁴⁵⁶⁷] = [¹²³⁴⁵⁶⁷]; кегля ниже 12px нет, переполнения нет, консоль
чиста. Регрессия экранов владельца после правки словаря — чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…стным

Перестроен по итогам поиска реальной статистики. Экран стал не отчётом,
а аргументом, и делится на три части.

ЧАСТЬ 1 — ЧТО ИЗВЕСТНО СЕГОДНЯ. Настоящие опубликованные числа, у каждого
проставлены год и источник:
  ВДС туризма 58 237,8 млн сом (2024) · доля в ВВП 3,8 % (2024)
  инвестиции 23 313,0 млн сом (2024) · посетителей 3 658,5 тыс. (2024)
  услуги гостиниц и ресторанов 73 275,2 млн сом (2025), из них
  проживание 11 087,9 · санатории и учреждения отдыха 186 (2024)
Плюс график ВДС 2020–2024: рост в 3,1 раза.
Источники разделены явно: ряды Нацстаткома (машиночитаемые, датированные)
и публикации СМИ (помечены как несверенные с первоисточником).

ЧАСТЬ 2 — ЧЕГО НЕТ НИ У КОГО. Проверено 08.08.2026 по всем разделам
открытых данных Нацстаткома. Не публикуется: номерной фонд, предложение
в номеро-сутках, продано номеро-суток, загрузка, ADR/RevPAR, разбивка по
районам (ряд «по территории» обрывается на 2017 году и приведён в сомах).
Загрузку в стране сегодня не может вычислить никто — нет знаменателя.

ЧАСТЬ 3 — ЧТО ДАЁТ NOMADCORE. Те же четыре показателя, измеренные.
Прежние три разреза со сверками остались без изменений.

ГЛАВНАЯ ПРАВКА ЧЕСТНОСТИ: полоса «синтетические данные» переехала вниз,
в третью часть. Раньше она стояла под шапкой и подписывала бы как
выдуманную чужую опубликованную статистику — на государственном экране
это прямая ложь. В проверку добавлен автоматический контроль порядка:
полоса обязана стоять НИЖЕ официальной части.

Официальные данные вынесены в отдельный модуль fixtures/gov-official.ts —
смешивать их с синтетикой пилотного контура в одном файле нельзя.

SeasonCurve обобщён до BarChart и используется дважды (ВДС и сезонность);
столбцов на графиках теперь 12, и это проверяется.

Починена полоса заголовка третьей части: фон стоял на абзаце с
ограниченной мерой строки и обрывался, оставляя справа белый провал.

Проверка: 5 сверок из 5, сноски [¹²³⁴⁵⁶⁷], 10 официальных показателей,
6 пробелов, порядок полосы верный — в светлой и тёмной теме. Экран
районов и экраны владельца без регрессий.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/DEMO-SCRIPT.md — сценарий на 15 минут ядра. Логика разговора:
его цифры → разрыв → откуда берутся данные → что получается → где
начать → просьба. Не «посмотрите, какое приложение», а «у вас нет
величины, и вот откуда она возьмётся».

Что внутри: подготовка за 15 минут до (какие вкладки, какой режим окна,
что проверить), пошаговые действия с точными кликами и репликами,
красная линия про добровольность данных — проговорить САМОМУ до того,
как он спросит, восемь вероятных вопросов с ответами, чего не делать,
и одна фраза на выход.

Ключевые решения сценария:
- начинать с ЕГО опубликованных цифр, а не с приложения: он видит, что
  вы знаете отрасль, а не пришли с презентацией;
- разрыв подаётся проверяемым утверждением — предложить открыть
  stat.gov.kg прямо на встрече;
- на телефоне ровно три касания, и главное из них стык: это то место,
  где тетрадь ошибается, и отраслевой человек это узнаёт мгновенно;
- просьба — не деньги: легитимность, канал, один район на один сезон.

apps/web/test/demo-preflight.mjs — прогон всех 30 утверждений сценария
против живого приложения. Проверяет счётчики 3/2/1, фильтрацию плиткой,
стык в строке номера 3, отказ сервера словами, наличие и подписи
официальных цифр, порядок полосы «синтетические данные», сходимость
сверок, переход по ссылке на районы и числа пилота.

Playwright намеренно НЕ добавлен в зависимости: это разовый инструмент
перед встречей, а не часть сборки. Инструкция запуска — в шапке файла.

Прогнано: 30 из 30 шагов сходятся.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.omc/ висел в статусе как неотслеживаемый — рано или поздно попал бы
в коммит вместе с сессиями и логами. Игнорируется содержимое, а не сам
каталог: .omc/skills — скиллы, привязанные к проекту, и они должны
версионироваться, а при шаблоне «.omc/» git внутрь не заходит и
исключение не срабатывает.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@spelingbee
spelingbee merged commit bad32e9 into main Aug 10, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant