Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
263a502
chore(design): импорт дизайн-системы и референса из Claude Design
spelingbee Aug 7, 2026
d8d1402
refactor(web): удалить старые экраны и глобальную оболочку
spelingbee Aug 7, 2026
c68b223
feat(web): подключить дизайн-систему
spelingbee Aug 7, 2026
b69dad1
feat(web): экран «Сегодня» на дизайн-системе
spelingbee Aug 7, 2026
42a7c8e
feat(web): экран «Занятость» — День / Ночи / Сетка
spelingbee Aug 7, 2026
906b140
feat(web): карточка брони
spelingbee Aug 7, 2026
2d22147
feat(web): экран «Очередь» и аддитивное расширение офлайн-слоя
spelingbee Aug 7, 2026
0e8639a
feat(web): экран входа переверстан на токенах
spelingbee Aug 7, 2026
21da911
fix(web): офлайн-слой виден во всех четырёх точках присутствия
spelingbee Aug 7, 2026
84c3bb5
feat(web): создание брони
spelingbee Aug 7, 2026
5b2c6fb
perf(web): Dexie грузится лениво, а не во входном чанке
spelingbee Aug 7, 2026
b8e0245
feat(design): токен --nc-action-danger-text для подписи необратимого …
spelingbee Aug 7, 2026
2ea5f35
fix(api): починить typecheck, build и test:races
spelingbee Aug 7, 2026
8828794
fix(web): ярлык номера не влезал в колонку «Сетки»
spelingbee Aug 7, 2026
922cb67
feat(web): довести до состояния, показуемого владельцу
spelingbee Aug 7, 2026
b20c0a7
refactor(web): переделать режим «День» — строка снова несёт факты
spelingbee Aug 7, 2026
1a88265
feat(web): кабинет госагентства — загрузка и номерной фонд
spelingbee Aug 7, 2026
dcd2074
feat(web): разрез по районам Иссык-Куля и кандидат на пилот
spelingbee Aug 7, 2026
4dd424f
feat(web): гос-экран построен вокруг разрыва между известным и неизве…
spelingbee Aug 7, 2026
c94c70f
docs: сценарий показа госагентству и прогон сценария перед встречей
spelingbee Aug 7, 2026
0aa6eaf
chore: не версионировать рабочее состояние оркестратора
spelingbee Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ coverage/
.idea/
.turbo/

# Рабочее состояние оркестратора: сессии, логи, черновики планов.
# Исключение — .omc/skills: скиллы, привязанные к проекту, версионируются.
.omc/*
!.omc/skills/

2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test src/bookings/domain.test.ts src/telegram/bot-flow.test.ts",
"test:races": "node --experimental-strip-types --test test/booking-races.integration.test.ts",
"test:races": "node --import tsx --test test/booking-races.integration.test.ts",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts"
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/public/public.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Param,
Post,
Query,
Type,
UnauthorizedException,
UseGuards,
} from "@nestjs/common"
Expand Down Expand Up @@ -56,7 +57,14 @@ type Bucket = { count: number; resetAt: number }
* Простой in-memory rate limit по IP — без внешних зависимостей.
* Для одного инстанса достаточно; при масштабировании — вынести в Redis.
*/
function makeRateLimitGuard(limit: number, windowMs: number) {
/* Возвращаемый тип указан ЯВНО. Без него TypeScript выводит тип
безымянного класса с приватным полем `buckets`, не может назвать его в
объявлении экспортируемых констант ниже и падает с TS4094 — из-за чего
не проходили ни `pnpm typecheck`, ни `nest build`. */
function makeRateLimitGuard(
limit: number,
windowMs: number,
): Type<CanActivate> {
@Injectable()
class RateLimitGuard implements CanActivate {
private readonly buckets = new Map<string, Bucket>()
Expand Down
22 changes: 19 additions & 3 deletions apps/api/test/booking-races.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ describe("анти-овербукинг под конкуренцией", { skip

before(async () => {
const { PrismaClient } = await import("@prisma/client")
const { BookingsService } = await import("../src/bookings/bookings.service")
const { OutboxService } = await import("../src/outbox/outbox.service")
// Расширение .ts обязательно: файл запускается напрямую через
// node --experimental-strip-types, а ESM-резолвер Node не достраивает
// расширения. Без него тест падал с ERR_MODULE_NOT_FOUND ещё до
// подключения к базе — то есть инвариант ADR-1 не проверялся ни разу.
const { BookingsService } = await import(
"../src/bookings/bookings.service.ts"
)
const { OutboxService } = await import("../src/outbox/outbox.service.ts")

prisma = new PrismaClient()
service = new BookingsService(prisma, new OutboxService())
Expand Down Expand Up @@ -80,13 +86,23 @@ describe("анти-овербукинг под конкуренцией", { skip
})

it("back-to-back брони проходят (выезд = заезд)", async () => {
// Предыдущий тест занял этот же номер на 01–05.09. Здесь заезд ровно
// в день выезда: интервал полуоткрытый, [checkIn, checkOut), поэтому
// пересечения нет и создание обязано пройти. Сам факт отсутствия
// исключения и есть проверка.
const b = await service.create(ownerId, {
roomId,
checkIn: "2026-09-05",
checkOut: "2026-09-08",
guestName: "Гость В",
})
assert.equal(b.status, "HOLD")
// CONFIRMED, а не HOLD: владелец заводит бронь уже подтверждённой
// (bookings.service.ts, create → status: "CONFIRMED").
// HOLD приходит только из Telegram-бота и с публичной витрины —
// его владелец подтверждает вручную. Прежнее ожидание HOLD было
// списано со значения по умолчанию в схеме и устарело.
assert.equal(b.status, "CONFIRMED")
assert.equal(b.checkIn.toISOString().slice(0, 10), "2026-09-05")
})
})

Expand Down
249 changes: 15 additions & 234 deletions apps/web/app.vue
Original file line number Diff line number Diff line change
@@ -1,236 +1,17 @@
<script setup lang="ts">
/**
* Корень приложения. Стилей здесь нет и не будет: раньше в этом файле жили
* ~230 строк глобального CSS, и он был фактической дизайн-системой проекта.
* Теперь единственный источник правды — design-system/tokens.css,
* подключённый через nuxt.config.
*/
const { init } = useTheme()

onMounted(init)
</script>

<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

<style>
/* Дизайн-система NomadCore — токены и базовые компоненты.
Источник: Notion «NomadCore — Дизайн-система (UI/UX)» §2–§3. */
:root {
/* Цвет (§2.1): «горы и войлок» — тёмно-бирюзовый + терракота */
--color-primary: #0f6b63;
--color-primary-pressed: #0a4e48;
--color-primary-soft: #e0efed;
--color-accent: #d96c3d;
--color-accent-pressed: #b85427;
--color-bg: #f7f6f3;
--color-surface: #ffffff;
--color-text: #1c2321;
--color-text-secondary: #5a6663;
--color-border: #dddad3;
--color-success: #1e7f3c;
--color-warning: #b45309;
--color-danger: #c0392b;

/* Семантика статусов броней (§2.1) */
--st-hold: #b45309;
--st-hold-bg: #fcefdc;
--st-confirmed: #1e7f3c;
--st-confirmed-bg: #e3f2e7;
--st-checkedin: #0f6b63;
--st-checkedin-bg: #e0efed;
--st-checkedout: #5a6663;
--st-checkedout-bg: #edece8;
--st-cancelled: #c0392b;
--st-cancelled-bg: #fbe7e4;

/* Геометрия (§2.3) */
--radius-card: 16px;
--radius-btn: 12px;
--radius-cell: 6px;
--shadow-float: 0 2px 8px rgba(0, 0, 0, 0.12);
--tap: 48px;

/* Алиасы для существующего кода */
--brand: var(--color-primary);
--brand-strong: var(--color-primary-pressed);
--brand-soft: var(--color-primary-soft);
--accent: var(--color-accent);
--bg: var(--color-bg);
--surface: var(--color-surface);
--border: var(--color-border);
--text: var(--color-text);
--muted: var(--color-text-secondary);
--danger: var(--color-danger);
--hold: var(--st-hold);
--hold-bg: var(--st-hold-bg);
--confirmed: var(--st-confirmed);
--confirmed-bg: var(--st-confirmed-bg);
--checkedin: var(--st-checkedin);
--checkedin-bg: var(--st-checkedin-bg);
--checkedout: var(--st-checkedout);
--checkedout-bg: var(--st-checkedout-bg);
--cancelled: var(--st-cancelled);
--cancelled-bg: var(--st-cancelled-bg);
--radius: var(--radius-card);
--radius-sm: var(--radius-btn);
--shadow: var(--shadow-float);
}

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: system-ui, Roboto, "Segoe UI", sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.45;
}

/* Типографика (§2.2) */
h1 { font-size: 22px; font-weight: 700; margin: 0; }
h2 { font-size: 18px; font-weight: 600; margin: 0 0 8px; }
.display { font-size: 28px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.muted { color: var(--color-text-secondary); font-size: 14px; }
.num { font-variant-numeric: tabular-nums; }

.app { display: flex; flex-direction: column; min-height: 100vh; }

/* Верхняя панель + индикатор синхронизации (§5) */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top));
background: var(--color-primary); color: #fff;
}
.topbar .logo { font-size: 17px; font-weight: 700; letter-spacing: 0.2px; }
.sync {
display: inline-flex; align-items: center; gap: 6px;
font-size: 14px; color: rgba(255, 255, 255, 0.9);
}
.sync.quiet { opacity: 0.65; }
.sync svg { width: 16px; height: 16px; flex-shrink: 0; }

/* Чипы статусов (§3.3): фон + точка-индикатор + текст */
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 14px; font-weight: 600;
padding: 3px 12px; border-radius: 999px;
white-space: nowrap;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.badge.st-HOLD { background: var(--st-hold-bg); color: var(--st-hold); }
.badge.st-CONFIRMED { background: var(--st-confirmed-bg); color: var(--st-confirmed); }
.badge.st-CHECKED_IN { background: var(--st-checkedin-bg); color: var(--st-checkedin); }
.badge.st-CHECKED_OUT { background: var(--st-checkedout-bg); color: var(--st-checkedout); }
.badge.st-CANCELLED { background: var(--st-cancelled-bg); color: var(--st-cancelled); }

.content {
flex: 1; display: flex; flex-direction: column;
padding: 16px;
padding-bottom: calc(96px + env(safe-area-inset-bottom));
}

/* Нижняя навигация (§3.6): 4 вкладки, монохромные иконки, активная — primary */
.tabbar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 20;
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 4px;
background: var(--color-surface); border-top: 1px solid var(--color-border);
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
}
.tabbar .tab {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
min-height: var(--tap);
text-decoration: none; color: var(--color-text-secondary);
font-size: 14px; padding: 6px 2px; border-radius: var(--radius-btn);
}
.tabbar .tab svg { width: 22px; height: 22px; }
.tabbar .tab.router-link-exact-active {
color: var(--color-primary); font-weight: 700;
background: var(--color-primary-soft);
}

/* Кнопки (§3.1) */
button {
font: inherit; font-size: 16px;
min-height: var(--tap);
padding: 10px 16px;
border: 1.5px solid var(--color-primary);
border-radius: var(--radius-btn);
background: transparent;
color: var(--color-primary);
cursor: pointer;
transition: transform 150ms, background 150ms;
}
button:active { transform: scale(0.98); }
button.primary {
background: var(--color-primary); border-color: var(--color-primary); color: #fff;
font-weight: 600;
min-height: 56px; width: 100%;
}
button.primary:active { background: var(--color-primary-pressed); }
button.danger { border: none; background: transparent; color: var(--color-danger); }
button:disabled { opacity: 0.6; cursor: default; }

/* FAB «+ Бронь» (§3.1): терракота, только на «Сегодня» и «Календаре» */
.fab {
position: fixed; right: 16px; bottom: calc(84px + env(safe-area-inset-bottom)); z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
min-height: 56px; width: auto; padding: 0 20px;
border: none; border-radius: 999px;
background: var(--color-accent); color: #fff;
font-size: 16px; font-weight: 700;
box-shadow: var(--shadow-float);
}
.fab:active { background: var(--color-accent-pressed); transform: scale(0.98); }
.fab svg { width: 20px; height: 20px; }

/* Поля ввода (§3.2): label всегда НАД полем */
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span { font-size: 14px; color: var(--color-text-secondary); }
input, select {
width: 100%;
min-height: 52px;
padding: 12px 14px; font-size: 16px; font-family: inherit;
border: 1px solid var(--color-border); border-radius: var(--radius-btn);
background: var(--color-surface);
color: var(--color-text);
}
input:focus-visible, select:focus-visible, button:focus-visible {
outline: 2px solid var(--color-primary); outline-offset: 1px;
}

/* Карточки: фон + рамка, без тяжёлых теней (§2.3) */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 16px;
}

/* Шторка снизу (§3.5) */
.overlay {
position: fixed; inset: 0; z-index: 30;
background: rgba(28, 35, 33, 0.45);
display: flex; align-items: flex-end; justify-content: center;
}
.sheet {
width: 100%; max-width: 480px;
background: var(--color-surface);
border-radius: var(--radius-card) var(--radius-card) 0 0;
padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
max-height: 88vh; overflow-y: auto;
}
.sheet::before {
content: ""; display: block;
width: 44px; height: 4px; border-radius: 999px;
background: var(--color-border);
margin: 4px auto 12px;
}

/* Живые пустые состояния (§3.7) */
.empty {
display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
padding: 16px;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: var(--radius-card);
color: var(--color-text-secondary); font-size: 16px;
}
.empty p { margin: 0; }
.empty button { width: auto; }
</style>
6 changes: 6 additions & 0 deletions apps/web/assets/css/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* Подключается ПОСЛЕ design-system/tokens.css: утилиты перекрывают базу,
а не наоборот. Своих правил здесь нет — сбросы и базовая типографика
живут в tokens.css, это единственный источник правды. */
@tailwind base;
@tailwind components;
@tailwind utilities;
Loading
Loading