diff --git a/.gitignore b/.gitignore index f4566ea..0cebd20 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ coverage/ .idea/ .turbo/ +# Рабочее состояние оркестратора: сессии, логи, черновики планов. +# Исключение — .omc/skills: скиллы, привязанные к проекту, версионируются. +.omc/* +!.omc/skills/ + diff --git a/apps/api/package.json b/apps/api/package.json index 76a9846..4c7c70a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index 81b8b3e..c64ed70 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -10,6 +10,7 @@ import { Param, Post, Query, + Type, UnauthorizedException, UseGuards, } from "@nestjs/common" @@ -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 { @Injectable() class RateLimitGuard implements CanActivate { private readonly buckets = new Map() diff --git a/apps/api/test/booking-races.integration.test.ts b/apps/api/test/booking-races.integration.test.ts index 5af59b3..6966de8 100644 --- a/apps/api/test/booking-races.integration.test.ts +++ b/apps/api/test/booking-races.integration.test.ts @@ -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()) @@ -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") }) }) diff --git a/apps/web/app.vue b/apps/web/app.vue index 7f20625..ea0393b 100644 --- a/apps/web/app.vue +++ b/apps/web/app.vue @@ -1,236 +1,17 @@ + + - - diff --git a/apps/web/assets/css/tailwind.css b/apps/web/assets/css/tailwind.css new file mode 100644 index 0000000..81afcbf --- /dev/null +++ b/apps/web/assets/css/tailwind.css @@ -0,0 +1,6 @@ +/* Подключается ПОСЛЕ design-system/tokens.css: утилиты перекрывают базу, + а не наоборот. Своих правил здесь нет — сбросы и базовая типографика + живут в tokens.css, это единственный источник правды. */ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/web/components/AppTabBar.vue b/apps/web/components/AppTabBar.vue new file mode 100644 index 0000000..ff9efb9 --- /dev/null +++ b/apps/web/components/AppTabBar.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/apps/web/components/BarChart.vue b/apps/web/components/BarChart.vue new file mode 100644 index 0000000..7f4b00a --- /dev/null +++ b/apps/web/components/BarChart.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/apps/web/components/BookingRow.vue b/apps/web/components/BookingRow.vue new file mode 100644 index 0000000..e351b58 --- /dev/null +++ b/apps/web/components/BookingRow.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/web/components/NewBookingSheet.vue b/apps/web/components/NewBookingSheet.vue new file mode 100644 index 0000000..a52ab6b --- /dev/null +++ b/apps/web/components/NewBookingSheet.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/apps/web/components/OccupancyDay.vue b/apps/web/components/OccupancyDay.vue new file mode 100644 index 0000000..881f74f --- /dev/null +++ b/apps/web/components/OccupancyDay.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/apps/web/components/OccupancyGrid.vue b/apps/web/components/OccupancyGrid.vue new file mode 100644 index 0000000..aa1973b --- /dev/null +++ b/apps/web/components/OccupancyGrid.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/apps/web/components/OccupancyNights.vue b/apps/web/components/OccupancyNights.vue new file mode 100644 index 0000000..d879c3a --- /dev/null +++ b/apps/web/components/OccupancyNights.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/apps/web/components/RoomChip.vue b/apps/web/components/RoomChip.vue new file mode 100644 index 0000000..5edb210 --- /dev/null +++ b/apps/web/components/RoomChip.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/apps/web/components/SyncBar.vue b/apps/web/components/SyncBar.vue new file mode 100644 index 0000000..06dcc5c --- /dev/null +++ b/apps/web/components/SyncBar.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/apps/web/composables/useApi.ts b/apps/web/composables/useApi.ts index af67788..719f84e 100644 --- a/apps/web/composables/useApi.ts +++ b/apps/web/composables/useApi.ts @@ -5,7 +5,29 @@ * Записи: при офлайне кладутся в очередь outbox (IndexedDB) и проигрываются * при появлении сети (last-write-wins + проверка version на сервере). */ -import { db, type PendingMutation } from "~/offline/db" +import type { PendingMutation } from "~/offline/db" + +/** + * Dexie грузится ЛЕНИВО и один раз. + * + * Раньше `~/offline/db` импортировался статически, и Dexie (~29 КБ gzip) + * попадал во входной чанк, то есть в первую загрузку владельца на дешёвом + * Android — при том что до первого чтения кэша или первой мутации он не + * нужен. Это же предписывает nuxt/README.md. + * + * Поведение не меняется: все обращения к базе и так живут внутри async- + * функций, добавляется только ожидание уже начатого импорта. Модуль + * запоминается в промисе, поэтому чанк запрашивается ровно один раз. + * + * Офлайн это не ломает: service worker прекэширует всю статику + * (globPatterns в nuxt.config), и после первой успешной загрузки чанк + * доступен без сети. + */ +let offlineModule: Promise | null = null +function offline(): Promise { + offlineModule ??= import("~/offline/db") + return offlineModule +} export function useApi() { const config = useRuntimeConfig() @@ -30,6 +52,7 @@ export function useApi() { : {}, }) if (method === "GET") { + const { db } = await offline() await db.cachedReads.put({ key: cacheKey(path, opts.query), payload: JSON.stringify(result), @@ -39,6 +62,7 @@ export function useApi() { return result } catch (error) { if (isOffline(error)) { + const { db } = await offline() if (method === "GET") { const cached = await db.cachedReads.get(cacheKey(path, opts.query)) if (cached) return JSON.parse(cached.payload) as T @@ -54,8 +78,20 @@ export function useApi() { } async function flushQueue(): Promise { + const { db } = await offline() const pending = await db.pendingMutations.orderBy("createdAt").toArray() + /* Брони с нерешённой мутацией. Блокируется только СВОЯ бронь — + иначе один спорный гость останавливает весь дом. */ + const blocked = new Set() + for (const mutation of pending) { + // Решения владельца ждут молча: сами не отправляются никогда. + if (mutation.state === "conflict" || mutation.state === "rejected") { + if (mutation.bookingId) blocked.add(mutation.bookingId) + continue + } + if (mutation.bookingId && blocked.has(mutation.bookingId)) continue + try { await $fetch(`${config.public.apiBase}${mutation.path}`, { method: mutation.method, @@ -67,8 +103,36 @@ export function useApi() { await db.pendingMutations.delete(mutation.id!) } catch (error) { if (isOffline(error)) break // сеть снова пропала — повторим позже - // Конфликт (409 OVERBOOKING и т.п.): отбрасываем мутацию, - // помечаем для ручного разбора владельцем. + + /* Раскладка по РЕАЛЬНОМУ контракту, а не по ожидаемому. + Сегодня 409 у этого API означает бизнес-отказ + ({code:"OVERBOOKING"}, {code:"INVALID_TRANSITION"}), + а конфликт версий приходит пятисоткой и здесь неотличим. + Ветка с телом booking — задел: она оживёт без правок + клиента, когда API начнёт отдавать тело 409. + Обоснование и постановка: nuxt/API-CONFLICTS.md */ + const status = httpStatus(error) + const data = httpData(error) + + if (status === 409 && data?.booking) { + await db.pendingMutations.update(mutation.id!, { + state: "conflict", + server: data, + }) + if (mutation.bookingId) blocked.add(mutation.bookingId) + continue + } + if (status === 409 || status === 422 || status === 423) { + await db.pendingMutations.update(mutation.id!, { + state: "rejected", + error: data?.code ?? data?.message ?? String(status), + }) + if (mutation.bookingId) blocked.add(mutation.bookingId) + continue + } + + // Всё остальное — прежнее поведение, байт в байт: + // отбрасываем мутацию, помечаем для ручного разбора владельцем. await db.conflicts.add({ mutation: JSON.stringify(mutation), reason: String((error as Error).message ?? error), @@ -80,13 +144,63 @@ export function useApi() { syncPending.value = await db.pendingMutations.count() } + /** Владелец выбрал «Оставить моё»: отправляем заново на свежей версии. */ + async function retryMutation(id: number): Promise { + const { db } = await offline() + const m = await db.pendingMutations.get(id) + if (!m) return + await db.pendingMutations.update(id, { + state: "pending", + error: undefined, + server: undefined, + baseVersion: versionOf(m.server) ?? m.baseVersion, + }) + syncPending.value = await db.pendingMutations.count() + await flushQueue() + } + + /** Владелец выбрал «Отклонить»: мутация удаляется, пометка снимается. */ + async function discardMutation(id: number): Promise { + const { db } = await offline() + await db.pendingMutations.delete(id) + syncPending.value = await db.pendingMutations.count() + } + async function enqueueMutation( mutation: Omit, ) { - await db.pendingMutations.add({ ...mutation, createdAt: Date.now() }) + const { db, bookingIdFromPath } = await offline() + await db.pendingMutations.add({ + ...mutation, + createdAt: Date.now(), + // Добавлено в версии 2: без bookingId очередь не умеет блокировать + // одну бронь и блокировала бы либо всё, либо ничего. + bookingId: mutation.bookingId ?? bookingIdFromPath(mutation.path), + state: "pending", + attempts: 0, + }) } - return { request, flushQueue } + return { request, flushQueue, retryMutation, discardMutation } +} + +/** ofetch кладёт код в status или statusCode — читаем оба. */ +function httpStatus(error: unknown): number | undefined { + const e = error as { status?: number; statusCode?: number } | null + return e?.status ?? e?.statusCode +} + +function httpData( + error: unknown, +): { booking?: unknown; code?: string; message?: string } | undefined { + return (error as { data?: { booking?: unknown; code?: string; message?: string } } | null) + ?.data +} + +function versionOf(server: unknown): number | undefined { + const v = (server as { booking?: { version?: number } } | undefined)?.booking + ?.version + return typeof v === "number" ? v : undefined } function cacheKey( diff --git a/apps/web/composables/useAvailability.ts b/apps/web/composables/useAvailability.ts new file mode 100644 index 0000000..1957fe8 --- /dev/null +++ b/apps/web/composables/useAvailability.ts @@ -0,0 +1,157 @@ +import type { Booking, Room } from "~/types" +import { addDays, coversNight, eachDay, overlaps, toDay } from "~/utils/dates" + +/** + * Занятость считается ИЗ БРОНЕЙ, а не из GET /api/availability, и это + * осознанно: + * + * 1. Ответ /availability РАЗРЕЖЁН — свободных дней в нём нет вовсе, только + * занятые ячейки. Ось дней всё равно пришлось бы достраивать здесь. + * 2. В ячейке нет checkIn/checkOut, поэтому многодневную полосу «Сетки» + * пришлось бы собирать группировкой по bookingId. + * 3. Главное: /availability не знает про локальные, ещё не отправленные + * изменения. Владелец офлайн продлил гостя — сетка обязана это показать + * сразу, иначе он переселит в занятый номер. + * + * Список броней уже загружен «Сегодня» и покрывает горизонт в 21 день. + */ + +/** + * Ширина колонки дня в «Сетке» — значение --nc-space-48. + * + * Не 44: полоса брони идёт от середины дня заезда до середины дня выезда, + * то есть её ширина равна CELL × число ночей минус 4px зазора. При CELL 44 + * бронь на одну ночь давала полосу 40px — тап-цель ниже предела в 44. + * При 48 однодневная полоса ровно 44px, то есть --nc-touch-min. + */ +const CELL_PX = 48 +/** Окно всех трёх режимов. 14 колонок — две недели, дальше владелец не планирует. */ +export const WINDOW_DAYS = 14 + +export interface NightRow { + date: string + weekend: boolean + free: Room[] + full: boolean + cells: { room: Room; booking: Booking | null; leaving: Booking | null }[] +} + +export interface Bar { + booking: Booking + left: number + width: number +} + +export function useAvailability() { + const { rooms, today } = useProperty() + const { live } = useBookings() + + /** Начало окна — вчера: вчерашние выезды объясняют сегодняшние стыки. */ + const windowStart = computed(() => addDays(today.value, -1)) + const windowEnd = computed(() => addDays(windowStart.value, WINDOW_DAYS)) + const columns = computed(() => eachDay(windowStart.value, WINDOW_DAYS)) + + /** Бронь, занимающая эту НОЧЬ. Выезд в этот день ночь не занимает. */ + function occupiedOn(room: Room, day: string): Booking | null { + return ( + live.value.find((b) => b.roomId === room.id && coversNight(b, day)) ?? null + ) + } + + /** Бронь, которая ИМЕННО В ЭТОТ ДЕНЬ выезжает из номера. */ + function leavingOn(room: Room, day: string): Booking | null { + return ( + live.value.find((b) => b.roomId === room.id && toDay(b.checkOut) === day) ?? + null + ) + } + + /** + * День, когда один гость выезжает, а другой заезжает в тот же номер. + * Это НЕ конфликт: [checkIn, checkOut) — ночь принадлежит дню заезда. + */ + function isTurnDay(room: Room, day: string): boolean { + const out = leavingOn(room, day) + if (!out) return false + return live.value.some( + (b) => b.roomId === room.id && toDay(b.checkIn) === day && b.id !== out.id, + ) + } + + function freeRooms(checkIn: string, checkOut: string): Room[] { + return rooms.value.filter( + (r) => + !live.value.some( + (b) => b.roomId === r.id && overlaps(b, { checkIn, checkOut }), + ), + ) + } + + /** + * Режим «Ночи». Строка = одна ночь, поэтому полуинтервал невозможно + * прочитать неправильно: у ночи ровно один постоялец, «наложения» + * не существует по построению. + */ + const nightRows = computed(() => + columns.value.map((date) => { + const free = freeRooms(date, addDays(date, 1)) + return { + date, + weekend: [0, 6].includes(new Date(`${date}T00:00:00Z`).getUTCDay()), + free, + full: free.length === 0, + cells: rooms.value.map((room) => ({ + room, + booking: occupiedOn(room, date), + leaving: leavingOn(room, date), + })), + } + }), + ) + + /** + * Режим «Сетка». Полоса идёт от СЕРЕДИНЫ дня заезда до СЕРЕДИНЫ дня + * выезда — поэтому «встык» даёт касание, а не наложение, и это видно + * геометрически, без легенды. + */ + function barsFor(room: Room): Bar[] { + const start = windowStart.value + return live.value + .filter( + (b) => + b.roomId === room.id && + overlaps(b, { checkIn: start, checkOut: windowEnd.value }), + ) + .map((b) => { + const dayIndex = (d: string) => + Math.round( + (Date.parse(`${d}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / + 86_400_000, + ) + const left = Math.max(0, (dayIndex(toDay(b.checkIn)) + 0.5) * CELL_PX) + const right = Math.min( + (WINDOW_DAYS - 0.5) * CELL_PX, + (dayIndex(toDay(b.checkOut)) + 0.5) * CELL_PX, + ) + return { + booking: b, + left: left + 2, + width: Math.max(20, right - left - 4), + } + }) + } + + return { + rooms, + today, + columns, + windowStart, + nightRows, + occupiedOn, + leavingOn, + isTurnDay, + freeRooms, + barsFor, + cell: CELL_PX, + } +} diff --git a/apps/web/composables/useBookings.ts b/apps/web/composables/useBookings.ts new file mode 100644 index 0000000..ee1baac --- /dev/null +++ b/apps/web/composables/useBookings.ts @@ -0,0 +1,213 @@ +import type { Booking, BookingStatus } from "~/types" +import { NEXT } from "~/types" +import { demoBookings } from "~/fixtures/demo" +import { addDays, nights, toDay } from "~/utils/dates" + +/** + * Брони объекта. Один общий кэш на приложение: «Сегодня», «Занятость» и + * карточка читают его же, потому что GET /api/bookings/:id на сервере НЕ + * СУЩЕСТВУЕТ и открыть одну бронь иначе нечем. + * + * Про окно запроса. Условие на сервере — `checkIn < to && checkOut > from` + * (apps/api/src/bookings/bookings.service.ts). Запрос from=сегодня даёт + * `checkOut > сегодня` и МОЛЧА выбрасывает все сегодняшние выезды — ровно + * те брони, ради которых экран «Сегодня» существует. Поэтому окно всегда + * расширено на сутки назад, а нужные брони отбираются уже здесь. + */ +export function useBookings() { + const config = useRuntimeConfig() + const { request } = useApi() + const { today, rooms } = useProperty() + + const all = useState("nc-bookings", () => []) + const pending = useState("nc-bookings-pending", () => false) + const failed = useState("nc-bookings-failed", () => false) + + /** Сколько дней вперёд держим в кэше: 14 колонок «Занятости» плюс запас. */ + const HORIZON_DAYS = 21 + /** Компенсация серверного фильтра, см. комментарий выше. */ + const BACKFILL_DAYS = 1 + + async function load(force = false): Promise { + if (pending.value) return + if (all.value.length && !force) return + pending.value = true + failed.value = false + try { + if (config.public.demo) { + all.value = demoBookings(today.value) + } else { + const from = addDays(today.value, -BACKFILL_DAYS) + const to = addDays(today.value, HORIZON_DAYS) + all.value = await request("/bookings", { + query: { from, to }, + }) + } + } catch { + failed.value = true + } finally { + pending.value = false + } + } + + /** Отменённые не занимают номер и не участвуют ни в одном списке. */ + const live = computed(() => all.value.filter((b) => b.status !== "CANCELLED")) + + const holds = computed(() => live.value.filter((b) => b.status === "HOLD")) + + const departures = computed(() => + live.value.filter( + (b) => b.status === "CHECKED_IN" && toDay(b.checkOut) === today.value, + ), + ) + + const arrivals = computed(() => + live.value.filter( + (b) => b.status === "CONFIRMED" && toDay(b.checkIn) === today.value, + ), + ) + + const inHouse = computed(() => + live.value.filter( + (b) => b.status === "CHECKED_IN" && toDay(b.checkOut) > today.value, + ), + ) + + function byId(id: string): Booking | null { + return all.value.find((b) => b.id === id) ?? null + } + + function patchLocal(id: string, patch: Partial): void { + all.value = all.value.map((b) => (b.id === id ? { ...b, ...patch } : b)) + } + + /** + * Переход по статусной машине. Единственная мутация брони, которую + * умеет API: PATCH /api/bookings/:id/status с телом { status }. + * Дат, номера и гостя изменить нечем — PATCH /api/bookings не существует. + * + * Изменение применяется локально СРАЗУ: экран не ждёт сеть. Если сети + * нет, useApi положит мутацию в очередь и вернёт { queued: true }. + */ + async function changeStatus( + booking: Booking, + status: BookingStatus, + ): Promise { + patchLocal(booking.id, { status, sync: "pending" }) + if (config.public.demo) { + patchLocal(booking.id, { sync: "ok", version: booking.version + 1 }) + return + } + try { + const saved = await request( + `/bookings/${booking.id}/status`, + { method: "PATCH", body: { status } }, + ) + if ("queued" in saved) return // ушло в офлайн-очередь, пометка остаётся + patchLocal(booking.id, { ...saved, sync: "ok" }) + } catch { + patchLocal(booking.id, { status: booking.status, sync: "rejected" }) + throw new Error("STATUS_CHANGE_FAILED") + } + } + + /** Следующий шаг машины или null, если состояние терминальное. */ + function nextStatus(booking: Booking): BookingStatus | null { + return NEXT[booking.status] + } + + /** + * Создание брони. POST /api/bookings — единственная мутация, кроме смены + * статуса, которую умеет сервер. + * + * Гость передаётся ИНЛАЙНОМ (guestName): DTO это допускает, и отдельный + * POST /api/guests не нужен. Это важно для офлайна — две мутации подряд, + * из которых вторая зависит от id первой, очередь провести не смогла бы. + * + * priceTotal и source сервер принимает, но ИГНОРИРУЕТ: цену он считает + * сам, source всегда "manual". Поэтому не отправляем их вовсе. + * + * Идемпотентности у эндпоинта нет: повторная отправка из очереди после + * оборвавшейся сети создаст дубль. См. docs/WEB-API-GAPS.md. + */ + async function create(input: { + roomId: string + checkIn: string + checkOut: string + guestName: string + }): Promise<{ ok: true } | { ok: false; code: string }> { + if (config.public.demo) { + all.value = [...all.value, buildLocalBooking(input, "ok")] + return { ok: true } + } + try { + const saved = await request("/bookings", { + method: "POST", + body: input, + }) + if ("queued" in saved) { + // Сети нет: мутация в очереди, показываем бронь локально + // с пометкой «Не отправлено». Экран не ждёт сервер. + all.value = [...all.value, buildLocalBooking(input, "pending")] + } else { + all.value = [...all.value, { ...saved, sync: "ok" }] + } + return { ok: true } + } catch (error) { + const data = (error as { data?: { code?: string } } | null)?.data + return { ok: false, code: data?.code ?? "CREATE_FAILED" } + } + } + + /** Локальное представление ещё не подтверждённой сервером брони. */ + function buildLocalBooking( + input: { roomId: string; checkIn: string; checkOut: string; guestName: string }, + sync: "ok" | "pending", + ): Booking { + const room = rooms.value.find((r) => r.id === input.roomId) + const nightCount = nights(input.checkIn, input.checkOut) + const rate = Number(room?.roomType?.basePrice ?? "0") + return { + id: `local-${input.roomId}-${input.checkIn}`, + propertyId: room?.propertyId ?? "", + roomId: input.roomId, + guestId: "", + checkIn: `${input.checkIn}T00:00:00.000Z`, + checkOut: `${input.checkOut}T00:00:00.000Z`, + // Сервер создаёт бронь сразу подтверждённой, не HOLD. + status: "CONFIRMED", + priceTotal: (rate * nightCount).toFixed(2), + source: "manual", + notes: null, + version: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + guest: { + id: "", + propertyId: room?.propertyId ?? "", + name: input.guestName, + phone: null, + notes: null, + }, + room, + sync, + } + } + + return { + all, + live, + holds, + departures, + arrivals, + inHouse, + pending, + failed, + load, + byId, + changeStatus, + nextStatus, + patchLocal, + create, + } +} diff --git a/apps/web/composables/useDateText.ts b/apps/web/composables/useDateText.ts new file mode 100644 index 0000000..e77361a --- /dev/null +++ b/apps/web/composables/useDateText.ts @@ -0,0 +1,67 @@ +import { dayNumber, dayOfWeek, monthIndex, nights, toDay } from "~/utils/dates" +import { pluralForm } from "~/utils/plural" + +/** + * Печать дат словами. Все строки — из словаря i18n, ни одного названия месяца + * в коде: кыргызские месяцы и дни недели пишутся иначе, а не транслитерацией. + * + * Intl.DateTimeFormat здесь НЕ используется: у него нет кыргызской локали + * в старых Android WebView, и он молча отдаёт английские названия. + */ +export function useDateText() { + const { t, tm, rt } = useI18n() + + function fromList(key: string, index: number): string { + const list = tm(key) as unknown[] + const item = list?.[index] + return item ? rt(item as never) : "" + } + + /** + * «7 авг» по-русски, «7-авг» по-кыргызски. + * + * Порядок и разделитель заданы ШАБЛОНОМ в словаре, а не склейкой в коде: + * в кыргызском число пишется с порядковым дефисом («7-август»), и + * зашитый пробел выдавал бы русскую форму на кыргызском экране. + */ + function shortDate(value: string): string { + const day = toDay(value) + return t("common.datePattern.short", { + d: dayNumber(day), + m: fromList("common.monthsShort", monthIndex(day)), + }) + } + + /** «7 авг → 9 авг». Стрелка читается как полуинтервал: выехал — освободил. */ + function dateRange(from: string, to: string): string { + return `${shortDate(from)} → ${shortDate(to)}` + } + + /** «Пятница, 7 августа» по-русски, «Жума, 7-август» по-кыргызски. */ + function longDay(value: string): string { + const day = toDay(value) + return t("common.datePattern.long", { + d: dayNumber(day), + m: fromList("common.monthsLong", monthIndex(day)), + w: fromList("common.weekdays", dayOfWeek(day)), + }) + } + + /** «Пт» */ + function shortWeekday(value: string): string { + return fromList("common.weekdaysShort", dayOfWeek(toDay(value))) + } + + /** «2 ночи» — форма выбирается по числу, строки лежат в словаре. */ + function nightsText(from: string, to: string): string { + const n = nights(toDay(from), toDay(to)) + return `${n} ${t(`common.nights.${pluralForm(n)}`)}` + } + + /** «3 свободно» — та же механика для номеров. */ + function freeText(n: number): string { + return `${n} ${t(`common.free.${pluralForm(n)}`)}` + } + + return { shortDate, dateRange, longDay, shortWeekday, nightsText, freeText } +} diff --git a/apps/web/composables/useNationalStats.ts b/apps/web/composables/useNationalStats.ts new file mode 100644 index 0000000..55d1207 --- /dev/null +++ b/apps/web/composables/useNationalStats.ts @@ -0,0 +1,118 @@ +import type { CategoryBase, MonthBase, RegionBase } from "~/fixtures/gov" + +/** + * Агрегаты для кабинета госагентства. + * + * ГЛАВНОЕ ПРАВИЛО: ни один показатель не хранится готовым, если его можно + * получить из соседних столбцов. Загрузка, ADR, средняя продолжительность + * и динамика — вычисляемые. Причина не в чистоте кода: показатель, который + * нельзя пересчитать калькулятором прямо на экране, обнуляет доверие ко + * всей остальной таблице. А на государственном экране доверие к цифре — + * это и есть продукт. + */ + +export interface RegionRow extends RegionBase { + /** Продано ÷ предложение. */ + occupancy: number + /** Выручка ÷ продано, сом за номеро-сутки. */ + adr: number + /** Продано ÷ заездов. */ + avgStay: number + /** Продано 2026 ÷ продано 2025 − 1. */ + yoy: number + /** Предложение ÷ номера. Справочная величина. */ + avgDaysOpen: number +} + +/** + * Средняя фактическая заполняемость номера. Прямого учёта спящих нет, + * поэтому койко-сутки — ОЦЕНКА, и она вынесена отдельно от загрузки, + * чтобы её нельзя было принять за измеренную величину. + */ +export const PAX_PER_ROOM = 2.3 + +export function derive(r: RegionBase): RegionRow { + return { + ...r, + occupancy: (r.sold / r.supply) * 100, + adr: (r.revenue * 1000) / r.sold, + avgStay: r.sold / r.checkins, + yoy: (r.sold / r.soldPrevYear - 1) * 100, + avgDaysOpen: r.supply / r.rooms, + } +} + +export interface Reconciliation { + /** Ключ словаря: что именно сверяется. Текст живёт в i18n, не здесь. */ + key: string + /** Левая и правая части, как они посчитаны. */ + left: number + right: number + ok: boolean +} + +export function useNationalStats( + regions: RegionBase[], + categories: CategoryBase[], + season: MonthBase[], +) { + const rows = computed(() => regions.map(derive)) + + const totals = computed(() => { + const sum = (f: (r: RegionBase) => number) => + regions.reduce((a, r) => a + f(r), 0) + const base: RegionBase = { + name: "Итого по республике", + objects: sum((r) => r.objects), + rooms: sum((r) => r.rooms), + supply: sum((r) => r.supply), + sold: sum((r) => r.sold), + soldPrevYear: sum((r) => r.soldPrevYear), + checkins: sum((r) => r.checkins), + revenue: sum((r) => r.revenue), + } + return { ...derive(base), bedNights: base.sold * PAX_PER_ROOM } + }) + + const categoryTotals = computed(() => { + const sum = (f: (c: CategoryBase) => number) => + categories.reduce((a, c) => a + f(c), 0) + return { + objects: sum((c) => c.objects), + rooms: sum((c) => c.rooms), + supply: sum((c) => c.supply), + sold: sum((c) => c.sold), + } + }) + + const seasonTotal = computed(() => season.reduce((a, m) => a + m.sold, 0)) + + /** + * Сверки. Считаются В РАНТАЙМЕ из тех же чисел, что нарисованы выше, + * и выводятся на экран словами. Смысл: любой, кто смотрит, может + * проверить их калькулятором, а расхождение двух разрезов означает + * ошибку выгрузки и видно сразу, а не через квартал. + */ + const reconciliations = computed(() => { + const t = totals.value + const c = categoryTotals.value + return [ + { key: "gov.rec.objects", left: t.objects, right: c.objects, ok: t.objects === c.objects }, + { key: "gov.rec.rooms", left: t.rooms, right: c.rooms, ok: t.rooms === c.rooms }, + { key: "gov.rec.supply", left: t.supply, right: c.supply, ok: t.supply === c.supply }, + { key: "gov.rec.sold", left: t.sold, right: c.sold, ok: t.sold === c.sold }, + { key: "gov.rec.season", left: seasonTotal.value, right: t.sold, ok: seasonTotal.value === t.sold }, + ] + }) + + const allReconcile = computed(() => reconciliations.value.every((r) => r.ok)) + + return { + rows, + totals, + categoryTotals, + seasonTotal, + reconciliations, + allReconcile, + } +} diff --git a/apps/web/composables/useProperty.ts b/apps/web/composables/useProperty.ts new file mode 100644 index 0000000..aff845f --- /dev/null +++ b/apps/web/composables/useProperty.ts @@ -0,0 +1,41 @@ +import type { Property, Room } from "~/types" +import { demoProperty } from "~/fixtures/demo" +import { todayIn } from "~/utils/dates" + +/** + * Объект владельца вместе с номерами. Один запрос на всё приложение: + * GET /properties/me отдаёт property + roomTypes[] + rooms[]{roomType}, + * поэтому отдельные /rooms и /room-types четырём экранам не нужны. + */ +export function useProperty() { + const config = useRuntimeConfig() + const { request } = useApi() + + const property = useState("nc-property", () => null) + const pending = useState("nc-property-pending", () => false) + const failed = useState("nc-property-failed", () => false) + + async function load(): Promise { + if (property.value || pending.value) return + pending.value = true + failed.value = false + try { + property.value = config.public.demo + ? demoProperty + : await request("/properties/me") + } catch { + // Офлайн без кэша или объект ещё не создан. Экран покажет пустое + // состояние, а не белый лист — молча падать здесь нельзя. + failed.value = true + } finally { + pending.value = false + } + } + + const rooms = computed(() => property.value?.rooms ?? []) + + /** «Сегодня» в таймзоне ОБЪЕКТА. До загрузки — Бишкек, как в схеме по умолчанию. */ + const today = computed(() => todayIn(property.value?.timezone ?? "Asia/Bishkek")) + + return { property, rooms, today, pending, failed, load } +} diff --git a/apps/web/composables/useQueue.ts b/apps/web/composables/useQueue.ts new file mode 100644 index 0000000..610a666 --- /dev/null +++ b/apps/web/composables/useQueue.ts @@ -0,0 +1,103 @@ +import type { Booking, BookingStatus, QueueItem } from "~/types" + +/** + * Очередь мутаций для интерфейса. Читает ту же таблицу Dexie, что ведёт + * useApi.ts, и переводит её в человеческие строки. + * + * Заголовок мутации выводится из тела запроса, а не хранится рядом с ним: + * сохранённый слой пишет в очередь только { path, method, body }, и + * заводить там ещё и подпись означало бы дублировать i18n в базе. + */ +const ACTION_KEY: Record = { + CONFIRMED: "booking.confirm", + CHECKED_IN: "booking.checkIn", + CHECKED_OUT: "booking.checkOut", + CANCELLED: "booking.cancelBooking", +} + +export function useQueue() { + const config = useRuntimeConfig() + const { t } = useI18n() + const { byId } = useBookings() + const { retryMutation, discardMutation } = useApi() + const { refresh, pending: pendingCount, broken: brokenCount } = useSyncState() + + const items = useState("nc-queue", () => []) + + function describe(bookingId: string | undefined, body: unknown): { + title: string + subtitle: string + } { + const booking = bookingId ? byId(bookingId) : null + const guest = booking?.guest?.name ?? "" + const status = (body as { status?: BookingStatus } | null)?.status + const key = status ? ACTION_KEY[status] : undefined + const action = key ? t(key) : t("queue.change") + return { + title: guest ? `${action} · ${guest}` : action, + subtitle: booking?.room?.label + ? t("card.room") + " " + booking.room.label + : "", + } + } + + async function load(): Promise { + if (config.public.demo) { + const { demoQueue } = await import("~/fixtures/demo") + items.value = demoQueue(t, byId) + // Полоса связи и счётчик на вкладке читают эти же две величины. + // Без этого демо показывало «Всё отправлено» поверх двух мутаций. + pendingCount.value = items.value.length + brokenCount.value = items.value.filter((i) => i.state !== "pending").length + return + } + const { db } = await import("~/offline/db") + const rows = await db.pendingMutations.orderBy("createdAt").toArray() + items.value = rows.map((m) => { + const text = describe(m.bookingId, m.body) + return { + id: m.id!, + state: m.state ?? "pending", + title: text.title, + subtitle: text.subtitle, + bookingId: m.bookingId, + error: m.error, + // Колонки «На телефоне» / «На сервере» заполняются, только + // если сервер прислал снимок. Сегодня API тела 409 не отдаёт, + // поэтому они пустые, и экран честно деградирует до + // «повторить или отклонить». Разбор — docs/WEB-API-GAPS.md. + theirs: undefined, + changedBy: m.server?.changedBy, + changedAt: m.server?.changedAt, + } satisfies QueueItem + }) + } + + async function retry(id: number): Promise { + if (config.public.demo) { + items.value = items.value.filter((i) => i.id !== id) + pendingCount.value = items.value.length + brokenCount.value = items.value.filter((i) => i.state !== "pending").length + return + } + await retryMutation(id) + await Promise.all([load(), refresh()]) + } + + async function discard(id: number): Promise { + if (config.public.demo) { + items.value = items.value.filter((i) => i.id !== id) + pendingCount.value = items.value.length + brokenCount.value = items.value.filter((i) => i.state !== "pending").length + return + } + await discardMutation(id) + await Promise.all([load(), refresh()]) + } + + const broken = computed( + () => items.value.filter((i) => i.state !== "pending").length, + ) + + return { items, broken, load, retry, discard } +} diff --git a/apps/web/composables/useSyncState.ts b/apps/web/composables/useSyncState.ts new file mode 100644 index 0000000..96bc629 --- /dev/null +++ b/apps/web/composables/useSyncState.ts @@ -0,0 +1,27 @@ +/** + * Состояние офлайн-слоя для интерфейса. Читает ту же очередь Dexie, которую + * ведёт сохранённый useApi.ts, и ничего в ней не меняет. + * + * Офлайн — штатный режим, а не авария, поэтому присутствие у него постоянное: + * полоса связи сверху, счётчик на вкладке «Очередь», пометка на конкретной + * броне. Всплывающих уведомлений нет. + */ +export function useSyncState() { + const { syncPending } = useSession() + + const online = useState("nc-online", () => true) + /** Мутации, которые сами не уедут: сервер их отверг. */ + const broken = useState("nc-broken", () => 0) + + async function refresh(): Promise { + if (!import.meta.client) return + // В демо-режиме счётчики ведёт useQueue из фикстур: настоящая + // очередь Dexie пуста, и чтение из неё обнулило бы демо. + if (useRuntimeConfig().public.demo) return + const { db } = await import("~/offline/db") + syncPending.value = await db.pendingMutations.count() + broken.value = await db.conflicts.count() + } + + return { online, pending: syncPending, broken, refresh } +} diff --git a/apps/web/composables/useTheme.ts b/apps/web/composables/useTheme.ts new file mode 100644 index 0000000..09286dd --- /dev/null +++ b/apps/web/composables/useTheme.ts @@ -0,0 +1,61 @@ +/** + * Тема оформления. Три состояния, а не два: + * + * system — атрибута на нет, решает @media (prefers-color-scheme) + * light — data-nc-theme="light", системная тёмная принудительно отключена + * dark — data-nc-theme="dark" + * + * Оба канала описаны в design-system/tokens.css и обязаны работать + * независимо: светлая тема выставлена не только на :root, но и на + * [data-nc-theme='light'], а медиазапрос закрыт гардом + * :root:not([data-nc-theme='light']) — иначе явный выбор «светлая» + * не пересилил бы системную тёмную. + */ +export type NcTheme = "system" | "light" | "dark" + +const STORAGE_KEY = "nc.theme" +const ORDER: NcTheme[] = ["system", "light", "dark"] + +function isTheme(value: unknown): value is NcTheme { + return value === "system" || value === "light" || value === "dark" +} + +export function useTheme() { + const theme = useState("nc-theme", () => "system") + + function apply(value: NcTheme): void { + if (!import.meta.client) return + const root = document.documentElement + if (value === "system") root.removeAttribute("data-nc-theme") + else root.setAttribute("data-nc-theme", value) + } + + function set(value: NcTheme): void { + theme.value = value + if (import.meta.client) localStorage.setItem(STORAGE_KEY, value) + apply(value) + } + + /** Вызывается один раз из app.vue: восстанавливает выбор до первой отрисовки. */ + function init(): void { + if (!import.meta.client) return + const saved = localStorage.getItem(STORAGE_KEY) + theme.value = isTheme(saved) ? saved : "system" + apply(theme.value) + } + + function cycle(): void { + set(ORDER[(ORDER.indexOf(theme.value) + 1) % ORDER.length]!) + } + + /** Что реально видит владелец сейчас — нужно для подписи переключателя. */ + const resolved = computed<"light" | "dark">(() => { + if (theme.value !== "system") return theme.value + if (!import.meta.client) return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + }) + + return { theme, resolved, set, init, cycle } +} diff --git a/apps/web/design-system/README.md b/apps/web/design-system/README.md new file mode 100644 index 0000000..5114c4a --- /dev/null +++ b/apps/web/design-system/README.md @@ -0,0 +1,121 @@ +# NomadCore — дизайн-система, этап 1 + +Что здесь лежит: **токены и примитивы**, а не макеты. + +``` +tokens.css все переменные: цвет, шкала, отступы, тач, движение +tailwind-preset.ts Tailwind читает те же переменные +primitives/*.vue NcButton, NcPill, NcRow, NcField, NcStepper, + NcSegmented, NcBanner, NcSheet, NcIcon +``` + +Спецификация с измеренным контрастом, шкалой и стресс-тестами — +`NomadCore Design System.dc.html` в корне проекта. + +## Три правила, из которых выведено остальное + +**1. Иерархия действий строится контрастом, а вес назначается по цене ошибки.** + +Контраст говорит, какое действие ГЛАВНОЕ, — он не означает «важное». Заливка +на экране ровно одна, у единственного главного действия; всё остальное — +контур. Уровни: + +| Вес | Когда | Пример | +|---|---|---| +| заливка | то, чего экран ждёт ПРЯМО СЕЙЧАС и что дёшево откатить | «Подтвердить · Петров Игорь» | +| контур | рутинное действие в строке | «Заселить», «Отклонить» | +| приглушённый контур + подтверждение | статус разойдётся с физическим миром | «Выселить» | +| красный контур | необратимое | «Отменить бронь» | +| только текст, значок в шапке | утилита и навигация, последствий ноль | «+», «КЫР» | + +Чем выше цена ошибки, тем менее нажимаемым выглядит действие. + +Три следствия, которые легко нарушить: + +- **Заливку получает не самое частое действие, а самое срочное.** «+ Новая + бронь» безопаснее всех и потому не залита вовсе: создание брони — это + «всегда», а экран «Сегодня» отвечает на «что делать сейчас». Значок «+» + 44×44 живёт в шапке. +- **Выселение не залито никогда**, в том числе на своей карточке. Оно + помечает гостя выехавшим, пока он в номере. +- **Когда экран ничего не требует, залитых кнопок ноль.** Это нормальное + состояние, а не пустота: на «Занятости» и на карточке гостя в доме + заливки нет. + +Переключатели режима (сегменты, вкладки) — состояние, а не действие: заливку +они не берут, выбранный помечается фоном band, жирным начертанием и +подчёркиванием. + +Два уточнения к счёту «ровно одна»: + +- **Модальная шторка считается отдельно.** Пока она открыта, фон под ней + неактивен, и у шторки своя единственная заливка. Две заливки на снимке — + норма, если одна из них принадлежит открытому слою. +- **Глобальное действие не повторяется в строках.** «Включить сеть» относится + ко всей очереди, а не к отдельной мутации: одна копия внизу экрана. + Дублирование в каждой карточке давало N+1 залитых кнопок — и, что хуже, + громче всех оказывалось самое безопасное действие. + +Проверка: обесцветьте экран и посчитайте залитые кнопки. Ровно одна — +иерархия есть; больше одной — главного действия нет; ноль — экран ничего не +требует, и это тоже ответ. Доказательства для экранов владельца: +`proof/grayscale-today.png`, `proof/grayscale-grid.png`. + +**2. Цвет означает ровно одну вещь.** +Янтарный — только «требует внимания»: статус HOLD и предупреждение о +документах. Он не заливает кнопки. Раньше «Подтвердить» был янтарным, и один +тон работал и действием, и предупреждением — это ошибка, исправлена. + +**3. Ниже 13px в поле не опускаемся.** +У шкалы `field` семь ступеней от 13 до 34px. Для кабинета госагентства есть +отдельная шкала `desk` от 12px — там ноутбук, а не солнце. + +## Цвета, измеренные к фону своей темы + +| Токен | Светлая | Тёмная | +|---|---|---| +| `--nc-text-primary` | 18,7:1 | 16,0:1 | +| `--nc-text-secondary` | 8,7:1 | 8,8:1 | +| `--nc-text-tertiary` | 5,2:1 | 5,4:1 | +| статусы с текстом на заливке | 6,6–6,7:1 | 6,8–7,4:1 | + +`#8A8C86` (3,4:1) и `#9A9C96` (2,8:1) из системы **исключены как текстовые** — +они годятся только на границы и неактивные точки шкалы состояния. + +## Тёмная тема — отдельный набор, не инверсия + +Сценарий другой: ночная стойка. Фон не чистый чёрный (дешёвый LCD показывает +серые пятна), текст не чистый белый (21:1 слепит в темноте, 16:1 достаточно). +Статусные тона подняты по светлоте с 0,47 до 0,72–0,76 и приглушены по +цветности, текст на заливке становится тёмным. + +## Имена отступов равны значениям + +`--nc-space-12` — это 12px. Индексных имён в системе нет: `space-4`, который +означает 12px, разработчик всё равно однажды прочитает как «4 пикселя». +Tailwind-шкала отступов переопределена целиком по той же причине — иначе +`p-4` (16px у Tailwind) и `space-4` (4px у нас) существуют одновременно. + +## Контраст проверяется на худшем фоне, а не на surface + +Правило: **тёмный текст проверяется на самом тёмном фоне своей темы, светлый — +на самом светлом.** Запас определяет худший случай, а не `--nc-bg-surface`. + +`--nc-text-tertiary` раньше был `#6B6D68`: 5,98:1 на белом — проходит, но на +`--nc-bg-canvas` `#EDEDEA` уже 4,46:1. Теперь `#626460`: 5,98 на surface, +5,10 на canvas, **4,56 на `--nc-bg-band` `#E1E1DC`** — это и есть худший фон +светлой темы. В тёмной теме худший фон, наоборот, самый светлый: +`--nc-text-tertiary` `#8B8F89` на `--nc-bg-band` `#242729` даёт 4,57:1. + +## Подключение + +```ts +// nuxt.config.ts +css: ['~/design-system/tokens.css'] +// tailwind.config.ts +import { ncPreset } from './design-system/tailwind-preset' +export default { presets: [ncPreset], content: [...] } +``` + +Инлайновых стилей в компонентах нет. Литералов цвета и кегля в компонентах нет. +Если примитиву не хватает токена — заводится токен, а не литерал. diff --git a/apps/web/design-system/STAGE-2.md b/apps/web/design-system/STAGE-2.md new file mode 100644 index 0000000..f69fd5d --- /dev/null +++ b/apps/web/design-system/STAGE-2.md @@ -0,0 +1,141 @@ +# Этап 2 — итог + +Составлено как требования по итогам разбора Этапа 1, переписано по факту +сделанного. Здесь — что построено и где это лежит; открытые вопросы вынесены +в конец. + +Состояние: **экраны готовы, можно запускать реализацию.** Одна зависимость от +бэкенда не закрыта — см. «Открытое». + +## 1. Занятость на 360px — сравнение проведено, вывод изменил план + +Файл: `NomadCore Занятость 360.dc.html` — три подхода живьём, не картинками. + +Замер по числу тапов до ответа: + +| Вопрос владельца | День | Сетка | Ночи | +|---|---|---|---| +| «Что у меня сегодня?» | **0** | 2 | 1 | +| «Есть места в субботу?» | 1 | 1 + скролл вбок | **1** | +| «Когда 2 свободные ночи подряд?» | 5+ | 1 + сканирование 140 клеток | **1** | +| «Куда переселить из номера 2?» | 3+ | **1 + скролл** | 2 | +| Одной рукой | да | **нет** | да | + +Ожидание было «День + Сетка». **Замер его не подтвердил:** на 360 сетка не +выигрывает ни одного вопроса, кроме перемещения между номерами. Её основную +работу — «свободно ли на выходные» — быстрее и одной рукой делает список +ночей. Третий кандидат сменился: вместо «Списка броней по датам заезда» в +сравнение вошли «Ночи», и они же победили. + +**Принято: День (умолчание) → Ночи → Сетка.** + +Стык `[checkIn, checkOut)` во всех трёх режимах: +- **День** — подпись словами: «Ким Мария → Chen Wei», тег «Стык». +- **Ночи** — ошибиться нельзя по построению: строка и есть ночь, у неё ровно + один постоялец, «наложения» не существует. +- **Сетка** — полоса от середины дня заезда до середины дня выезда. Проверено + геометрией: полосы Ким и Chen 7 августа в номере 3 соприкасаются с зазором + 4px, не перекрываются. + +Остальное из требования сделано: ночь 8 августа — 0 свободных из 10, HOLD из +Telegram отдельным состоянием с плашкой «Telegram». + +## 2. Арифметика госэкрана + +Файл: `NomadCore Госагентство.dc.html`, код — `nuxt/composables/useNationalStats.ts`. + +Требование «ни один показатель не хранится готовым» выполнено буквально: +базовых величин на регион семь (объекты, номера, предложение, продано, +продано в прошлом году, заезды, выручка), всё остальное — производное. +Загрузка по республике **21,0 %** = 378 200 ÷ 1 803 380. + +Добавлено сверх требования: +- Вторая таблица — разбивка по категориям классификации. Итоги двух таблиц + сходятся построчно (1 284 объекта / 11 590 номеров / 1 803 380 предложение / + 378 200 продано); расхождение означало бы ошибку выгрузки. +- Сумма сезонной кривой 2026 равна итогу «Продано» в таблице. +- Блок сверок внизу экрана выписывает эти проверки словами. +- Синтетическая природа данных подписана полосой под шапкой, а не сноской. + +## 3. Экраны владельца + +Файл: `NomadCore Владелец 360.dc.html` — 360 × 780, четыре экрана одним +приложением. + +- **Сегодня** отвечает на «что делать сейчас» без прокрутки: счётчик из трёх + плиток укладывается в первые 204px, плитки заодно фильтруют список. +- **Занятость** — три режима из п. 1. +- **Карточка брони** — статусная машина, продление, ранний выезд, документы. +- **Очередь** — четвёртым экраном. + +Офлайн — слой из четырёх точек присутствия, а не уведомление: полоса связи +сверху, счётчик на вкладке, пометка «Не отправлено» на конкретной броне, +экран разрешения конфликта. + +## 4. Иерархия действий — правило переписано + +Первая версия правила («иерархия контрастом») была применена буквально и дала +четыре залитых кнопки на экране. Вторая — убрала заливку у строк, но оставила +её у «+ Новая бронь», то есть у самого безопасного действия. Обе итерации +исправлены. + +Действующее правило: **вес назначается по цене ошибки.** Заливку получает то, +чего экран ждёт прямо сейчас и что дёшево откатить. Создание брони её не +получает никогда — это «всегда», а не «сейчас»: значок «+» 44×44 в шапке. +Выселение не получает никогда — статус разойдётся с физическим миром. Полная +таблица уровней и два уточнения к счёту — в `design-system/README.md`. + +Следствие, которое стоит знать заранее: **на «Занятости» и на карточке гостя +в доме залитых кнопок ноль.** Экран ничего не требует — это допустимое +состояние, а не недоделка. + +Доказательства: `proof/grayscale-today.png`, `proof/grayscale-grid.png`. + +## 5. Статус вторым каналом + +Три статусных тона выровнены по светлоте в OKLCH — в градациях серого они +неразличимы. Поэтому статус нигде не передаётся одним цветом: + +- в «Дне» и «Ночах» — слово в строке; +- в «Сетке» — глиф перед именем: `?` ожидает, `→` заедет, `●` в доме, + `✓` выехал. Печатается всегда и в бюджет усечения имени не входит: когда + имя не влезает, остаётся глиф, а не голая заливка. Легенда под сеткой. + +## Что не принимается (без изменений) + +- Инлайновые стили в компонентах. +- Кегль или цвет вне `tokens.css`. +- Кегль ниже 13px у владельца, ниже 12px в кабинете. +- Больше одной залитой кнопки на слое. +- Янтарный в заливке кнопки: он занят сигналом «требует внимания». +- Показатель, который нельзя пересчитать из соседних столбцов. +- Статус, переданный одним цветом. + +## Открытое: контракт разрешения конфликтов + +**Единственное, что блокирует полную реализацию офлайн-слоя.** + +Принято: last-write отменяется для `checkIn`, `checkOut`, `status`, `roomId`; +для документов, имени и заметок сохраняется. По четырём полям сервер обязан +вернуть обе версии и ждать выбора человека. + +Это меняет контракт API и на клиенте не закрывается. Постановка задачи, +обоснование, разделение полей, поведение очереди до выбора и при отсутствии +выбора — `nuxt/API-CONFLICTS.md`. + +Пока API не отдаёт тело 409, экран конфликта деградирует до «повторить или +отменить» — заглушка, а не рабочий сценарий. + +## Файлы к передаче + +``` +design-system/tokens.css токены: цвет, две шкалы, темы, тач, движение +design-system/tailwind-preset.ts те же переменные для Tailwind +design-system/primitives/*.vue 9 примитивов +design-system/README.md правила, в т.ч. иерархия по цене ошибки +nuxt/ composables, страницы, типы, Dexie-слой +nuxt/API-CONFLICTS.md постановка задачи на API +proof/*.png обесцвеченные доказательства иерархии +``` + +Прототипы (`*.dc.html`) — референс поведения, не исходник реализации. diff --git a/apps/web/design-system/primitives/NcBanner.vue b/apps/web/design-system/primitives/NcBanner.vue new file mode 100644 index 0000000..8b53728 --- /dev/null +++ b/apps/web/design-system/primitives/NcBanner.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcButton.vue b/apps/web/design-system/primitives/NcButton.vue new file mode 100644 index 0000000..2e8dabc --- /dev/null +++ b/apps/web/design-system/primitives/NcButton.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcField.vue b/apps/web/design-system/primitives/NcField.vue new file mode 100644 index 0000000..2846e2f --- /dev/null +++ b/apps/web/design-system/primitives/NcField.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcIcon.vue b/apps/web/design-system/primitives/NcIcon.vue new file mode 100644 index 0000000..429767b --- /dev/null +++ b/apps/web/design-system/primitives/NcIcon.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcPill.vue b/apps/web/design-system/primitives/NcPill.vue new file mode 100644 index 0000000..8656fea --- /dev/null +++ b/apps/web/design-system/primitives/NcPill.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcRow.vue b/apps/web/design-system/primitives/NcRow.vue new file mode 100644 index 0000000..0ae0b63 --- /dev/null +++ b/apps/web/design-system/primitives/NcRow.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcSegmented.vue b/apps/web/design-system/primitives/NcSegmented.vue new file mode 100644 index 0000000..01b3f06 --- /dev/null +++ b/apps/web/design-system/primitives/NcSegmented.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcSheet.vue b/apps/web/design-system/primitives/NcSheet.vue new file mode 100644 index 0000000..4f89e8c --- /dev/null +++ b/apps/web/design-system/primitives/NcSheet.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/web/design-system/primitives/NcStepper.vue b/apps/web/design-system/primitives/NcStepper.vue new file mode 100644 index 0000000..d8b286a --- /dev/null +++ b/apps/web/design-system/primitives/NcStepper.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/apps/web/design-system/tailwind-preset.ts b/apps/web/design-system/tailwind-preset.ts new file mode 100644 index 0000000..b58be09 --- /dev/null +++ b/apps/web/design-system/tailwind-preset.ts @@ -0,0 +1,67 @@ +import type { Config } from 'tailwindcss' + +/** + * Tailwind читает те же переменные, что и примитивы. Литералов цвета и кегля + * в утилитах нет — иначе система разъезжается на второй неделе. + * Подключение: presets: [ncPreset] в tailwind.config.ts. + */ +export const ncPreset: Partial = { + theme: { + /* spacing задан целиком, а не через extend: иначе рядом живут родная + шкала Tailwind (p-4 = 16px) и наша (space-4 = 4px) — гарантированная + путаница. Имя утилиты равно числу пикселей. */ + spacing: { + 0: '0', 2: 'var(--nc-space-2)', 4: 'var(--nc-space-4)', 8: 'var(--nc-space-8)', + 12: 'var(--nc-space-12)', 16: 'var(--nc-space-16)', 24: 'var(--nc-space-24)', + 32: 'var(--nc-space-32)', 48: 'var(--nc-space-48)' + }, + extend: { + colors: { + canvas: 'var(--nc-bg-canvas)', + surface: 'var(--nc-bg-surface)', + sunken: 'var(--nc-bg-sunken)', + band: 'var(--nc-bg-band)', + primary: 'var(--nc-text-primary)', + secondary: 'var(--nc-text-secondary)', + tertiary: 'var(--nc-text-tertiary)', + line: 'var(--nc-border-line)', + hair: 'var(--nc-border-hair)', + strong: 'var(--nc-border-strong)', + hold: 'var(--nc-status-hold)', + confirmed: 'var(--nc-status-confirmed)', + inhouse: 'var(--nc-status-inhouse)', + danger: 'var(--nc-action-danger-fg)' + }, + fontSize: { + 100: ['var(--nc-fs-100)', 'var(--nc-lh-100)'], + 200: ['var(--nc-fs-200)', 'var(--nc-lh-200)'], + 300: ['var(--nc-fs-300)', 'var(--nc-lh-300)'], + 400: ['var(--nc-fs-400)', 'var(--nc-lh-400)'], + 500: ['var(--nc-fs-500)', 'var(--nc-lh-500)'], + 600: ['var(--nc-fs-600)', 'var(--nc-lh-600)'], + 700: ['var(--nc-fs-700)', 'var(--nc-lh-700)'] + }, + borderRadius: { + sm: 'var(--nc-radius-sm)', md: 'var(--nc-radius-md)', + lg: 'var(--nc-radius-lg)', pill: 'var(--nc-radius-pill)' + }, + minHeight: { + touch: 'var(--nc-touch-min)', + action: 'var(--nc-touch-action)', + primary: 'var(--nc-touch-primary)' + }, + minWidth: { touch: 'var(--nc-touch-min)' }, + transitionDuration: { state: 'var(--nc-motion-state)' } + } + } +} + +/* Экспорт по умолчанию НАМЕРЕННО убран. + Раньше здесь стояло `export default ncPreset` — тот же самый объект и + именованным экспортом, и по умолчанию. Интероп CJS/ESM (jiti, через который + @nuxtjs/tailwindcss читает конфиг) в этом случае дописывает именованные + экспорты в объект default, а поскольку это один объект, получается + ncPreset.ncPreset === ncPreset. Глубокое слияние пресетов в Tailwind уходит + по этой петле в бесконечную рекурсию и сборка падает с + «RangeError: Maximum call stack size exceeded» в vite:css. + Подключение — только именованным импортом, как и написано в README. */ diff --git a/apps/web/design-system/tokens.css b/apps/web/design-system/tokens.css new file mode 100644 index 0000000..3f96cf3 --- /dev/null +++ b/apps/web/design-system/tokens.css @@ -0,0 +1,265 @@ +/* NomadCore — токены. Единственный источник правды по цвету, типографике, + размерам и движению. Ни один компонент не имеет права на литерал. + + Два контекста, две шкалы: + - field — владелец, телефон, солнце, одна рука. Кегль от 13px. + - desk — кабинет госагентства, ноутбук/проектор. Кегль от 12px, высокая плотность. + + Контраст измерен, а не оценён на глаз. Значения в комментариях — WCAG 2.1 + contrast ratio к фону своей темы. +*/ + +:root { + /* ── Шаг сетки ───────────────────────────────────────────────── + Число в имени — это ЗНАЧЕНИЕ в пикселях, а не порядковый номер. + --nc-space-4 равен 4px и ничему другому. Индексных имён + (space-1, space-2 …) в системе нет: они читаются как «первый шаг» + и разработчик пишет space-4, ожидая 4px, а получает 12. */ + --nc-space-0: 0; + --nc-space-2: 2px; + --nc-space-4: 4px; + --nc-space-8: 8px; + --nc-space-12: 12px; + --nc-space-16: 16px; + --nc-space-24: 24px; + --nc-space-32: 32px; + --nc-space-48: 48px; + + --nc-radius-sm: 4px; + --nc-radius-md: 6px; + --nc-radius-lg: 10px; + --nc-radius-pill: 999px; + + /* ── Толщины линий ───────────────────────────────────────────── + Названы stroke, а не border: --nc-border-* уже заняты ЦВЕТАМИ границ, + и две группы с одним префиксом читались бы как одна. */ + --nc-stroke-hair: 1px; /* разделитель строк списка, внутренняя сетка */ + --nc-stroke-control: 2px; /* рамка кнопки, поля, сегмента */ + /* Одно значение на четыре роли: маркер статуса слева от строки, левая полоса + плашки, подчёркивание выбранного сегмента, полоса ошибки поля. + Раньше 4px стояли литералом в каждом из примитивов. */ + --nc-stroke-accent: 4px; + + /* ── Тач-таргеты. Нижняя граница — не рекомендация, а предел ── */ + --nc-touch-min: 44px; /* всё интерактивное */ + --nc-touch-action: 56px; /* действие в строке списка */ + --nc-touch-primary: 64px; /* главное действие экрана, зона большого пальца */ + /* Кнопка в строке списка не должна быть уже, чем «Подтвердить» на кыргызском */ + --nc-btn-min-w: 114px; + /* Закреплённая колонка ярлыков в «Сетке». 48px хватало только на демо-номера + «1» и «Ю1»; в реальной базе номера называются «№101», и подпись наезжала + на дни. Ширина рассчитана на шесть знаков жирным --nc-fs-200. */ + --nc-grid-label-w: 76px; + + /* ── Типографика: 7 ступеней, минимум 13px ─────────────────── */ + --nc-fs-100: 13px; --nc-lh-100: 18px; /* подпись, единицы, сноска */ + --nc-fs-200: 15px; --nc-lh-200: 21px; /* вторичный текст строки */ + --nc-fs-300: 17px; --nc-lh-300: 23px; /* основной текст */ + --nc-fs-400: 20px; --nc-lh-400: 25px; /* имя гостя в строке, кнопка */ + --nc-fs-500: 24px; --nc-lh-500: 29px; /* подзаголовок экрана */ + --nc-fs-600: 28px; --nc-lh-600: 32px; /* заголовок экрана */ + --nc-fs-700: 34px; --nc-lh-700: 38px; /* число-герой */ + + /* Плотный контекст кабинета: отдельная шкала, минимум 12px */ + --nc-fs-desk-100: 12px; --nc-lh-desk-100: 16px; + --nc-fs-desk-200: 13px; --nc-lh-desk-200: 18px; + --nc-fs-desk-300: 15px; --nc-lh-desk-300: 20px; + --nc-fs-desk-400: 18px; --nc-lh-desk-400: 24px; + --nc-fs-desk-500: 22px; --nc-lh-desk-500: 28px; + --nc-fs-desk-600: 28px; --nc-lh-desk-600: 34px; + + --nc-fw-regular: 400; + --nc-fw-medium: 600; + --nc-fw-bold: 700; + + /* Межстрочный для подписи кнопки: она переносится на две строки на + кыргызском, и ступени --nc-lh-* там слишком просторны */ + --nc-lh-tight: 1.15; + /* Толщина штриха инлайнового SVG. Подобрана к весу текста, а не к сетке */ + --nc-icon-stroke: 2.2; + + --nc-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + /* Серифный стек только для кабинета: отделяет отчёт от приложения, вес 0 КБ */ + --nc-font-serif: Georgia, 'Times New Roman', serif; + --nc-tracking-caps: .06em; + + /* ── Движение: только сообщение о состоянии ────────────────── */ + --nc-motion-instant: 0ms; + --nc-motion-state: 140ms; /* появление/уход состояния */ + --nc-motion-swipe: 130ms; /* возврат строки после свайпа */ + --nc-ease: cubic-bezier(.2, 0, .2, 1); +} + +/* ══ СВЕТЛАЯ ТЕМА ═══════════════════════════════════════════════ + Подобрана под прямое солнце: белая бумага, почти чёрный текст, + заливки насыщенные — не пастельные, иначе на солнце они выцветают. */ +:root, +[data-nc-theme='light'] { + --nc-bg-canvas: #EDEDEA; + --nc-bg-surface: #FFFFFF; + --nc-bg-sunken: #F6F6F3; + --nc-bg-band: #E1E1DC; + + --nc-text-primary: #111310; /* 18.7:1 на surface */ + --nc-text-secondary: #4A4C48; /* 8.7:1 */ + /* Тёмный текст проверяется на САМОМ ТЁМНОМ фоне своей темы, а не на surface. + Худший фон светлой темы — --nc-bg-band #E1E1DC; прежний #6B6D68 давал + на нём 4,1:1, на canvas 4,46:1 — ниже порога. */ + --nc-text-tertiary: #626460; /* 5,98 surface · 5,10 canvas · 4,56 band */ + --nc-text-inverse: #FFFFFF; + + --nc-border-strong: #111310; + --nc-border-line: #C9C9C4; + --nc-border-hair: #DDDDD7; + + /* Иерархия действий строится КОНТРАСТОМ, а не цветом. + Главное действие — максимальный контраст с фоном. */ + --nc-action-primary-bg: #111310; + --nc-action-primary-fg: #FFFFFF; + --nc-action-secondary-bg: #FFFFFF; + --nc-action-secondary-fg: #111310; + --nc-action-secondary-bd: #111310; + /* ГРАНИЦА и акцент необратимого действия. 6,37:1 на surface, 4,86:1 на + худшем фоне светлой темы (band) — для границы достаточно (порог 3:1), + для ТЕКСТА нет. Текст берёт токен ниже. */ + --nc-action-danger-fg: #B03024; + /* ТЕКСТ необратимого действия. Отдельный токен, потому что у границы и у + подписи разные пороги: 3:1 против 7:1. Значение задано ссылкой, а не + копией числа: красный текст в системе один, и разъезжаться этим двум + ролям незачем — но теперь они МОГУТ разойтись, не ломая друг друга. + Замер: 10,37 surface · 8,84 canvas · 9,58 sunken · 7,90 band. */ + --nc-action-danger-text: var(--nc-signal-error-fg); + --nc-action-disabled-bg: #DDDDD7; + --nc-action-disabled-fg: #6B6D68; + + /* Статусы. Одинаковая светлота (L 0.46–0.48) — ни один не кричит громче. + Различимы и в оттенках серого: проверено обесцвечиванием. */ + --nc-status-hold: oklch(0.48 0.14 70); /* 6.7:1 с белым текстом */ + --nc-status-confirmed: oklch(0.48 0.14 255); /* 6.6:1 */ + --nc-status-inhouse: oklch(0.46 0.13 155); /* 6.6:1 */ + --nc-status-out: #DDDDD7; + --nc-status-cancelled: #B03024; + --nc-status-fg: #FFFFFF; + --nc-status-out-fg: #111310; + + /* Сигналы. Янтарный здесь — ТОЛЬКО предупреждение и статус ожидания, + никогда не заливка кнопки: иначе цвет означает две разные вещи. */ + --nc-signal-warning-bg: #F9F0DC; + --nc-signal-warning-fg: #4A3A12; /* 8.9:1 на своём фоне */ + --nc-signal-error-bg: #FCE9E7; + --nc-signal-error-fg: #7A1E16; /* 8.2:1 */ + --nc-signal-offline-bg: #111310; + --nc-signal-offline-fg: #FFFFFF; + --nc-signal-offline-dot:#F0B429; + + --nc-focus-ring: 0 0 0 3px #FFFFFF, 0 0 0 6px #111310; + --nc-shadow-sheet: 0 -8px 32px rgba(17, 19, 16, .18); + /* Затемнение под шторкой. Тот же тон, что и текст, на половине прозрачности */ + --nc-scrim: rgba(17, 19, 16, .5); +} + +/* ══ ТЁМНАЯ ТЕМА ════════════════════════════════════════════════ + Не инверсия светлой, а отдельный набор. Сценарий другой: + ночная стойка, тёмная комната, дешёвый LCD. + - Фон не чистый чёрный: на дешёвых матрицах чёрный «сереет» пятнами. + - Текст не чистый белый: 16:1 достаточно, 21:1 слепит в темноте. + - Статусные тона подняты по светлоте до 0.72–0.76 и приглушены по + цветности: те же тона в светлом варианте на тёмном фоне не читаются. + - Текст на статусной заливке становится тёмным. */ +[data-nc-theme='dark'] { + --nc-bg-canvas: #101214; + --nc-bg-surface: #16181A; + --nc-bg-sunken: #1D2023; + --nc-bg-band: #242729; + + --nc-text-primary: #F2F3F0; /* 16.0:1 на surface */ + --nc-text-secondary: #B4B7B2; /* 8.8:1 */ + /* В тёмной теме худший фон, наоборот, самый светлый — --nc-bg-band #242729 */ + --nc-text-tertiary: #8B8F89; /* 5,41 surface · 5,71 canvas · 4,57 band */ + --nc-text-inverse: #101214; + + --nc-border-strong: #F2F3F0; + --nc-border-line: #33383B; + --nc-border-hair: #26292C; + + /* Тот же принцип: главное действие — максимальный контраст с фоном. + В темноте это светлая заливка, а не тёмная. */ + --nc-action-primary-bg: #F2F3F0; + --nc-action-primary-fg: #101214; + --nc-action-secondary-bg: transparent; + --nc-action-secondary-fg: #F2F3F0; + --nc-action-secondary-bd: #7E837D; + --nc-action-danger-fg: oklch(0.72 0.15 25); /* граница, порог 3:1 */ + /* Замер: 10,41 surface · 10,97 canvas · 9,57 sunken · 8,79 band */ + --nc-action-danger-text: var(--nc-signal-error-fg); + --nc-action-disabled-bg: #26292C; + --nc-action-disabled-fg: #7E837D; + + --nc-status-hold: oklch(0.76 0.13 70); + --nc-status-confirmed: oklch(0.72 0.12 255); + --nc-status-inhouse: oklch(0.72 0.13 155); + --nc-status-out: #33383B; + --nc-status-cancelled: oklch(0.70 0.16 25); + --nc-status-fg: #101214; + --nc-status-out-fg: #F2F3F0; + + --nc-signal-warning-bg: #2E2515; + --nc-signal-warning-fg: #F3D9A0; + --nc-signal-error-bg: #2E1815; + --nc-signal-error-fg: #F5B7AF; + --nc-signal-offline-bg: #F2F3F0; + --nc-signal-offline-fg: #101214; + --nc-signal-offline-dot:#8A5B00; + + --nc-focus-ring: 0 0 0 3px #101214, 0 0 0 6px #F2F3F0; + --nc-shadow-sheet: 0 -8px 32px rgba(0, 0, 0, .55); + /* НЕ инверсия светлого: тон текста здесь светлый, и осветлять фон под + шторкой нельзя — панель станет темнее подложки. Затемняем чёрным, + глубже светлой темы, потому что контраст с surface #16181A мал. */ + --nc-scrim: rgba(0, 0, 0, .62); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-nc-theme='light']) { + color-scheme: dark; + --nc-bg-canvas: #101214; --nc-bg-surface: #16181A; --nc-bg-sunken: #1D2023; --nc-bg-band: #242729; + --nc-text-primary: #F2F3F0; --nc-text-secondary: #B4B7B2; --nc-text-tertiary: #8B8F89; --nc-text-inverse: #101214; + --nc-border-strong: #F2F3F0; --nc-border-line: #33383B; --nc-border-hair: #26292C; + --nc-action-primary-bg: #F2F3F0; --nc-action-primary-fg: #101214; + --nc-action-secondary-bg: transparent; --nc-action-secondary-fg: #F2F3F0; --nc-action-secondary-bd: #7E837D; + --nc-action-danger-fg: oklch(0.72 0.15 25); + --nc-action-danger-text: var(--nc-signal-error-fg); + --nc-action-disabled-bg: #26292C; --nc-action-disabled-fg: #7E837D; + --nc-status-hold: oklch(0.76 0.13 70); --nc-status-confirmed: oklch(0.72 0.12 255); + --nc-status-inhouse: oklch(0.72 0.13 155); --nc-status-out: #33383B; --nc-status-cancelled: oklch(0.70 0.16 25); + --nc-status-fg: #101214; --nc-status-out-fg: #F2F3F0; + --nc-signal-warning-bg: #2E2515; --nc-signal-warning-fg: #F3D9A0; + --nc-signal-error-bg: #2E1815; --nc-signal-error-fg: #F5B7AF; + --nc-signal-offline-bg: #F2F3F0; --nc-signal-offline-fg: #101214; --nc-signal-offline-dot: #8A5B00; + --nc-shadow-sheet: 0 -8px 32px rgba(0, 0, 0, .55); + --nc-scrim: rgba(0, 0, 0, .62); + --nc-focus-ring: 0 0 0 3px #101214, 0 0 0 6px #F2F3F0; + } +} + +@media (prefers-reduced-motion: reduce) { + :root { --nc-motion-state: 0ms; --nc-motion-swipe: 0ms; } +} + +/* Базовые сбросы — единственное, что этот файл рисует сам */ +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--nc-bg-canvas); + color: var(--nc-text-primary); + font-family: var(--nc-font-sans); + font-size: var(--nc-fs-300); + line-height: var(--nc-lh-300); + -webkit-font-smoothing: antialiased; +} +.nc-tnum { font-variant-numeric: tabular-nums; } +:where(button, a, input, select):focus-visible { + outline: none; + box-shadow: var(--nc-focus-ring); +} diff --git a/apps/web/fixtures/demo.ts b/apps/web/fixtures/demo.ts new file mode 100644 index 0000000..cc26e93 --- /dev/null +++ b/apps/web/fixtures/demo.ts @@ -0,0 +1,219 @@ +/* ───────────────────────────────────────────────────────────────────────── + * ДЕМО-ДАННЫЕ. ЭТО ЕДИНСТВЕННОЕ МЕСТО В ПРОЕКТЕ, ГДЕ ОНИ ЖИВУТ. + * + * Ни один компонент и ни один composable не имеет права заводить свои + * фикстуры: экран, который «работает» на локальном массиве, перестаёт + * показывать, что API чего-то не отдаёт. + * + * Включаются флагом runtimeConfig.public.demo (NUXT_PUBLIC_DEMO). + * Сейчас он включён по умолчанию, потому что живого бэкенда рядом нет. + * Выключить: NUXT_PUBLIC_DEMO=0 — тогда слой данных пойдёт в реальный API. + * + * ВАЖНО: набор содержит РОВНО те поля, которые есть в apps/api/prisma/ + * schema.prisma. Прототип показывает на карточке ещё четыре — код брони, + * гражданство, число гостей и отметку «паспорт предъявлен», — которых в + * схеме НЕТ. Они сюда намеренно не добавлены: фикстура с несуществующим + * полем делает экран «рабочим» ровно до первого запроса к живому API. + * Список пробелов — в docs/WEB-API-GAPS.md. + * + * Набор повторяет сценарий прототипа «NomadCore Владелец 360»: + * · 10 номеров, 13 броней; + * · сегодня 1 ожидает решения, 2 выезда, 3 заезда; + * · в номере 3 сегодня СТЫК — Ким Мария выезжает, Chen Wei заезжает, + * и это не конфликт: [checkIn, checkOut); + * · пиковая ночь «послезавтра» занята полностью, 0 свободных из 10. + * + * Почему пик именно на послезавтра, а не на завтра. Шторка создания брони + * открывается с умолчанием «сегодня, 2 ночи», то есть окном [0, +2). + * Когда полностью занята была ночь +1, это окно НИКОГДА не имело + * свободных номеров, и главный сценарий продукта с порога упирался + * в «Свободных номеров нет». Пик сдвинут на +2: окно по умолчанию + * находит номер 4, а пиковая ночь по-прежнему видна в «Ночах» и «Сетке». + * Даты заданы смещением от сегодняшнего дня, а не константами, иначе + * демонстрация протухает на следующие сутки. + * ───────────────────────────────────────────────────────────────────────── */ +import type { + Booking, + BookingStatus, + Guest, + Property, + QueueItem, + Room, + RoomType, +} from "~/types" +import { addDays, nights } from "~/utils/dates" + +const PROPERTY_ID = "demo-property" + +/** Названия типов номеров приходят из БД одной строкой и НЕ локализуются. */ +const ROOM_TYPES: RoomType[] = [ + { + id: "rt-std", + propertyId: PROPERTY_ID, + name: "Стандарт, 2 места", + capacity: 2, + basePrice: "1900.00", + }, + { + id: "rt-fam", + propertyId: PROPERTY_ID, + name: "Семейный, 4 места", + capacity: 4, + basePrice: "2600.00", + }, + { + id: "rt-yurt", + propertyId: PROPERTY_ID, + name: "Юрта, 4 места", + capacity: 4, + basePrice: "2600.00", + }, +] + +const ROOM_LABELS: ReadonlyArray<[label: string, roomTypeId: string]> = [ + ["1", "rt-std"], + ["2", "rt-std"], + ["3", "rt-std"], + ["4", "rt-std"], + ["5", "rt-std"], + ["6", "rt-std"], + ["7", "rt-fam"], + ["8", "rt-fam"], + ["Ю1", "rt-yurt"], + ["Ю2", "rt-yurt"], +] + +const ROOMS: Room[] = ROOM_LABELS.map(([label, roomTypeId]) => ({ + id: `room-${label}`, + propertyId: PROPERTY_ID, + roomTypeId, + label, + roomType: ROOM_TYPES.find((t) => t.id === roomTypeId)!, +})) + +export const demoProperty: Property = { + id: PROPERTY_ID, + name: "Ак-Марал", + slug: "ak-maral", + region: "Иссык-Куль", + address: "Боконбаево", + timezone: "Asia/Bishkek", + roomTypes: ROOM_TYPES, + rooms: ROOMS, +} + +export const demoRooms: Room[] = ROOMS +export const demoRoomTypes: RoomType[] = ROOM_TYPES + +/** + * [имя гостя, ярлык номера, смещение заезда, смещение выезда, статус, источник] + * Смещения — в днях от сегодняшнего дня объекта. + * Источник — свободная строка, как в схеме: manual | bot | marketplace. + */ +type Seed = [string, string, number, number, BookingStatus, string] + +const SEED: Seed[] = [ + ["Сыдыков Айбек", "1", -2, 4, "CHECKED_IN", "manual"], + // Единственная бронь, ждущая решения. Пришла из Telegram-бота. + ["Петров Игорь", "2", 0, 1, "HOLD", "bot"], + ["Осмонов Бакыт", "2", 1, 4, "CONFIRMED", "manual"], + // Ким выезжает сегодня, Chen заезжает сегодня в ТОТ ЖЕ номер 3 — стык. + ["Ким Мария", "3", -3, 0, "CHECKED_IN", "manual"], + ["Chen Wei", "3", 0, 3, "CONFIRMED", "manual"], + // Номер 4 освобождается сегодня и занимается только послезавтра — + // это тот самый номер, который находит шторка создания по умолчанию. + ["Ибраимова Айгүл", "4", -2, 0, "CHECKED_IN", "manual"], + ["Мамбетова Асель", "4", 2, 6, "CONFIRMED", "manual"], + ["Жумабаев Асан", "5", -1, 3, "CHECKED_IN", "manual"], + ["Ли Наталья", "6", 0, 4, "CONFIRMED", "manual"], + ["Familie Weber", "7", -1, 5, "CHECKED_IN", "manual"], + ["Wang Jing", "8", -3, 4, "CHECKED_IN", "manual"], + ["Асанова Гүлнара", "Ю1", 0, 4, "CONFIRMED", "manual"], + ["Токтогулов Эрлан", "Ю2", 1, 4, "CONFIRMED", "manual"], +] + +/** Сервер отдаёт @db.Date как ISO-строку с UTC-полуночью — повторяем ровно это. */ +function asServerDate(day: string): string { + return `${day}T00:00:00.000Z` +} + +/** + * Пометки на бронях согласованы с demoQueue ниже: офлайн-слой обязан быть + * виден в четырёх местах сразу — полоса связи, счётчик на вкладке, пометка + * на КОНКРЕТНОЙ броне и экран очереди. Без этой связки четвёртая точка + * присутствия в демо не показывалась. + */ +const DEMO_SYNC: Record = { + "booking-1": "pending", // «Подтвердить · Петров Игорь» ждёт отправки + "booking-4": "rejected", // «Заселить · Chen Wei» отклонён сервером +} + +export function demoBookings(today: string): Booking[] { + return SEED.map(([name, roomLabel, fromOffset, toOffset, status, source], i) => { + const room = ROOMS.find((r) => r.label === roomLabel)! + const checkIn = addDays(today, fromOffset) + const checkOut = addDays(today, toOffset) + const rate = Number(room.roomType!.basePrice) + const guest: Guest = { + id: `guest-${i}`, + propertyId: PROPERTY_ID, + name, + phone: `+9967000000${String(i).padStart(2, "0")}`, + notes: null, + } + return { + id: `booking-${i}`, + propertyId: PROPERTY_ID, + roomId: room.id, + guestId: guest.id, + checkIn: asServerDate(checkIn), + checkOut: asServerDate(checkOut), + status, + priceTotal: (rate * nights(checkIn, checkOut)).toFixed(2), + source, + notes: null, + version: 1, + createdAt: asServerDate(addDays(today, fromOffset - 7)), + updatedAt: asServerDate(addDays(today, fromOffset - 7)), + guest, + room, + sync: DEMO_SYNC[`booking-${i}`] ?? "ok", + } satisfies Booking + }) +} + +/** + * Демо-очередь: две строки, чтобы экран «Очередь» было на чём смотреть. + * + * Третьего состояния — `conflict` — здесь НЕТ намеренно. Конфликт версий + * требует, чтобы сервер вернул 409 с телом (обе версии, автор и время); + * сегодняшний API отдаёт на этот случай 500, отличить его не от чего. + * Показать конфликт в демо означало бы пообещать сценарий, которого в + * продукте нет. Разбор — docs/WEB-API-GAPS.md и nuxt/API-CONFLICTS.md. + */ +export function demoQueue( + t: (key: string, named?: Record) => string, + byId: (id: string) => Booking | null, +): QueueItem[] { + const label = (bookingId: string) => byId(bookingId)?.room?.label ?? "" + const guest = (bookingId: string) => byId(bookingId)?.guest?.name ?? "" + + return [ + { + id: 1, + state: "pending", + title: `${t("booking.confirm")} · ${guest("booking-1")}`, + subtitle: `${t("card.room")} ${label("booking-1")}`, + bookingId: "booking-1", + }, + { + id: 2, + state: "rejected", + title: `${t("booking.checkIn")} · ${guest("booking-4")}`, + subtitle: `${t("card.room")} ${label("booking-4")}`, + bookingId: "booking-4", + // Ровно то, что кладёт сервер: {code:"OVERBOOKING"} + error: "OVERBOOKING", + }, + ] +} diff --git a/apps/web/fixtures/gov-official.ts b/apps/web/fixtures/gov-official.ts new file mode 100644 index 0000000..31b06bb --- /dev/null +++ b/apps/web/fixtures/gov-official.ts @@ -0,0 +1,76 @@ +/* ───────────────────────────────────────────────────────────────────────── + * ОФИЦИАЛЬНАЯ СТАТИСТИКА. Это НЕ демо-данные. + * + * Файл лежит отдельно от fixtures/gov.ts намеренно: там синтетика + * пилотного контура, здесь — настоящие опубликованные числа. Смешивать + * их в одном модуле нельзя, иначе через месяц никто не вспомнит, где что. + * + * У каждой величины проставлен год и источник, и они выводятся на экран + * рядом с числом. Человек, которому это показывают, знает эти цифры + * лучше нас — подпись под каждой обязательна. + * + * Проверено 08.08.2026 по открытым данным Нацстаткома КР. + * ───────────────────────────────────────────────────────────────────────── */ + +export type SourceKind = "nsc" | "press" + +export interface OfficialFigure { + /** Ключ i18n с названием показателя. */ + key: string + value: string + unitKey: string + year: number + source: SourceKind +} + +/** + * Ключевые официальные показатели. + * nsc — Нацстатком КР, раздел «Открытые данные» (машиночитаемые ряды). + * press — публикации СМИ со ссылкой на ведомства; первоисточник не сверялся. + */ +export const OFFICIAL_FIGURES: OfficialFigure[] = [ + { key: "gov.off.gva", value: "58 237,8", unitKey: "gov.off.unit.mln", year: 2024, source: "nsc" }, + { key: "gov.off.gdpShare", value: "3,8", unitKey: "gov.off.unit.pct", year: 2024, source: "nsc" }, + { key: "gov.off.investment", value: "23 313,0", unitKey: "gov.off.unit.mln", year: 2024, source: "nsc" }, + { key: "gov.off.visitors", value: "3 658,5", unitKey: "gov.off.unit.thousand", year: 2024, source: "nsc" }, + { key: "gov.off.horeca", value: "73 275,2", unitKey: "gov.off.unit.mln", year: 2025, source: "nsc" }, + { key: "gov.off.lodging", value: "11 087,9", unitKey: "gov.off.unit.mln", year: 2025, source: "nsc" }, + { key: "gov.off.sanatoria", value: "186", unitKey: "gov.off.unit.units", year: 2024, source: "nsc" }, + { key: "gov.off.hotels", value: "388 / 98 / 17", unitKey: "gov.off.unit.units", year: 2025, source: "press" }, + { key: "gov.off.ikObjects", value: "1 833", unitKey: "gov.off.unit.units", year: 2025, source: "press" }, + { key: "gov.off.ikGuests", value: "2,63", unitKey: "gov.off.unit.mlnPeople", year: 2025, source: "press" }, +] + +/** + * Валовая добавленная стоимость туризма — единственный ряд, который есть + * целиком за пять лет. Показывает рост в 3,1 раза и потому стоит графиком, + * а не строкой. Нацстатком, открытые данные, млн сом. + */ +export const GVA_SERIES = [ + { name: "2020", value: 18506 }, + { name: "2021", value: 24971 }, + { name: "2022", value: 35158 }, + { name: "2023", value: 47855 }, + { name: "2024", value: 58238 }, +] + +/** + * Чего в официальной статистике НЕТ. Проверено по всем разделам открытых + * данных 08.08.2026. + * + * Это не упрёк ведомству, а описание механики: государство считает поток + * и деньги — сколько приехало и сколько потратили. Предложение оно не + * считает, потому что собрать его можно только снизу, с каждого объекта. + * Отсюда следует, что загрузку в стране сегодня не может вычислить никто: + * нет величины в знаменателе. + */ +export const MISSING_METRICS = [ + { key: "gov.gap.rooms", whyKey: "gov.gap.roomsWhy" }, + { key: "gov.gap.supply", whyKey: "gov.gap.supplyWhy" }, + { key: "gov.gap.sold", whyKey: "gov.gap.soldWhy" }, + { key: "gov.gap.occupancy", whyKey: "gov.gap.occupancyWhy" }, + { key: "gov.gap.adr", whyKey: "gov.gap.adrWhy" }, + { key: "gov.gap.district", whyKey: "gov.gap.districtWhy" }, +] + +export const OFFICIAL_CHECKED_AT = "08.08.2026" diff --git a/apps/web/fixtures/gov.ts b/apps/web/fixtures/gov.ts new file mode 100644 index 0000000..1466755 --- /dev/null +++ b/apps/web/fixtures/gov.ts @@ -0,0 +1,132 @@ +/* ───────────────────────────────────────────────────────────────────────── + * ДЕМО-ДАННЫЕ КАБИНЕТА ГОСАГЕНТСТВА. Синтетические, и это подписано + * на самом экране полосой под шапкой, а не сноской внизу. + * + * Структура показателей и порядок величин соответствуют реальной + * отчётности; сами значения — нет. + * + * ГЛАВНОЕ СВОЙСТВО НАБОРА: три разреза одной совокупности сходятся + * ПОСТРОЧНО. Регионы, категории классификации и сезонная кривая дают + * одни и те же итоги. Это не украшение: показатель, который нельзя + * пересчитать из соседних столбцов, обнуляет доверие к экрану целиком, + * а расхождение двух разрезов означает ошибку выгрузки. + * + * объектов 1 284 + * номеров 11 590 + * предложение 1 803 380 номеро-суток + * продано 378 200 номеро-суток + * загрузка 378 200 ÷ 1 803 380 = 21,0 % + * + * Проверки выписаны словами внизу экрана и считаются в рантайме, + * а не зашиты — см. useNationalStats(). + * ───────────────────────────────────────────────────────────────────────── */ + +/** Базовые величины региона. Всё, что не здесь, — производное. */ +export interface RegionBase { + name: string + objects: number + rooms: number + /** + * Номеро-сутки, ФАКТИЧЕСКИ выставленные к продаже. Гостевые дома + * сезонны: объект вне сезона закрыт и в знаменатель не входит. + * Календарный период знаменателем НЕ является — иначе загрузка + * Иссык-Куля выглядела бы вдвое ниже реальной. + */ + supply: number + sold: number + soldPrevYear: number + checkins: number + /** Тыс. сом. */ + revenue: number +} + +export const GOV_REGIONS: RegionBase[] = [ + { name: "Иссык-Кульская область", objects: 612, rooms: 5940, supply: 724680, sold: 214300, soldPrevYear: 158900, checkins: 46800, revenue: 610755 }, + { name: "Чуйская область", objects: 148, rooms: 1210, supply: 256520, sold: 38400, soldPrevYear: 31600, checkins: 11900, revenue: 84096 }, + { name: "г. Бишкек", objects: 121, rooms: 1480, supply: 313760, sold: 44200, soldPrevYear: 38100, checkins: 15300, revenue: 137904 }, + { name: "Нарынская область", objects: 96, rooms: 640, supply: 78080, sold: 15800, soldPrevYear: 10500, checkins: 5400, revenue: 28124 }, + { name: "Ошская область", objects: 104, rooms: 720, supply: 131040, sold: 19600, soldPrevYear: 13800, checkins: 6300, revenue: 33124 }, + { name: "г. Ош", objects: 63, rooms: 690, supply: 146280, sold: 21400, soldPrevYear: 17000, checkins: 7100, revenue: 47936 }, + { name: "Джалал-Абадская область", objects: 78, rooms: 520, supply: 94640, sold: 14900, soldPrevYear: 11400, checkins: 4800, revenue: 23989 }, + { name: "Таласская область", objects: 34, rooms: 210, supply: 25620, sold: 5200, soldPrevYear: 4300, checkins: 1900, revenue: 8008 }, + { name: "Баткенская область", objects: 28, rooms: 180, supply: 32760, sold: 4400, soldPrevYear: 2500, checkins: 1630, revenue: 6512 }, +] + +/** Второй разрез той же совокупности — по категориям классификации. */ +export interface CategoryBase { + name: string + objects: number + rooms: number + supply: number + sold: number +} + +export const GOV_CATEGORIES: CategoryBase[] = [ + { name: "Гостиницы 3–5★", objects: 86, rooms: 3240, supply: 686880, sold: 118900 }, + { name: "Гостиницы 1–2★ и без категории", objects: 178, rooms: 2130, supply: 361620, sold: 61400 }, + { name: "Гостевые дома", objects: 742, rooms: 3980, supply: 466480, sold: 121300 }, + { name: "Пансионаты и базы отдыха", objects: 194, rooms: 1810, supply: 235300, sold: 63200 }, + { name: "Юртовые лагеря", objects: 84, rooms: 430, supply: 53100, sold: 13400 }, +] + +/** Третий разрез — помесячно. Сумма обязана равняться «Продано». */ +export interface MonthBase { + name: string + sold: number +} + +export const GOV_SEASON: MonthBase[] = [ + { name: "Январь", sold: 14200 }, + { name: "Февраль", sold: 15800 }, + { name: "Март", sold: 19400 }, + { name: "Апрель", sold: 26700 }, + { name: "Май", sold: 44900 }, + { name: "Июнь", sold: 108300 }, + { name: "Июль", sold: 148900 }, +] + +/** + * Разрез по районам Иссык-Кульской области. + * + * Экран отвечает не на «какая загрузка», а на «ГДЕ ДЕЛАТЬ ПИЛОТ», поэтому + * главная величина здесь — охват, а не продажи. Суммы всех семи базовых + * столбцов обязаны совпасть со строкой «Иссык-Кульская область» из разреза + * по регионам, а охват — с 61 %, заявленными на общем экране. Обе сверки + * считаются в рантайме и выведены на экран. + */ +export interface DistrictBase extends RegionBase { + /** + * Оценка ВСЕГО фонда района, включая неподключённые объекты. + * Источник оценки — реестр классификации плюс полевой обход; это + * единственная величина на экране, которая не измерена системой, + * и она подписана как оценка. + */ + fund: number +} + +export const ISSYK_KUL_DISTRICTS: DistrictBase[] = [ + { name: "Иссык-Кульский район (Чолпон-Ата)", objects: 214, fund: 302, rooms: 2380, supply: 285600, sold: 92400, soldPrevYear: 68200, checkins: 19800, revenue: 277200 }, + { name: "Джети-Огузский район", objects: 118, fund: 186, rooms: 1090, supply: 130800, sold: 38600, soldPrevYear: 28900, checkins: 8400, revenue: 104220 }, + { name: "г. Каракол", objects: 96, fund: 141, rooms: 830, supply: 149400, sold: 33200, soldPrevYear: 25600, checkins: 7900, revenue: 92960 }, + { name: "Тонский район (Боконбаево)", objects: 74, fund: 168, rooms: 520, supply: 52000, sold: 16800, soldPrevYear: 11900, checkins: 3700, revenue: 40320 }, + { name: "Ак-Суйский район", objects: 44, fund: 89, rooms: 380, supply: 41800, sold: 12100, soldPrevYear: 8600, checkins: 2600, revenue: 29040 }, + { name: "Тюпский район", objects: 34, fund: 74, rooms: 280, supply: 30800, sold: 8900, soldPrevYear: 6100, checkins: 2000, revenue: 20470 }, + { name: "г. Балыкчы", objects: 32, fund: 43, rooms: 460, supply: 34280, sold: 12300, soldPrevYear: 9600, checkins: 2400, revenue: 46545 }, +] + +/** Область, которую детализирует экран районов. */ +export const DRILLDOWN_REGION = "Иссык-Кульская область" + +/** + * Охват. Показатели по стране ЗАНИЖЕНЫ на величину неохваченного фонда, + * и это должно быть сказано на экране, а не подразумеваться: чиновник + * понесёт цифру наверх, и она обязана быть с оговоркой. + */ +export const GOV_COVERAGE = [ + { region: "Иссык-Кульская область", share: 61 }, + { region: "г. Бишкек", share: 48 }, + { region: "Баткенская область", share: 12 }, +] + +export const GOV_PERIOD = "январь — июль 2026" +export const GOV_EXPORTED_AT = "07.08.2026, 06:00 (UTC+6)" diff --git a/apps/web/i18n/i18n.config.ts b/apps/web/i18n/i18n.config.ts new file mode 100644 index 0000000..aacacc8 --- /dev/null +++ b/apps/web/i18n/i18n.config.ts @@ -0,0 +1,17 @@ +/** + * Опции самого vue-i18n. Модульные настройки (locales, langDir, strategy) + * остаются в nuxt.config — здесь только то, что модуль передаёт внутрь. + * + * fallbackLocale нужен двум местам: + * · кабинет госагентства ведётся только по-русски — это официальная + * отчётность, и переводить её на кыргызский в интерфейсе бессмысленно, + * пока агентство само публикует статистику по-русски; + * · en.json остался с прежним набором ключей и новыми не пополнялся. + * + * Без него отсутствующий ключ печатается на экране путём вида + * «gov.col.occupancy» — на государственном экране это недопустимо. + */ +export default defineI18nConfig(() => ({ + legacy: false, + fallbackLocale: "ru", +})) diff --git a/apps/web/i18n/locales/ky.json b/apps/web/i18n/locales/ky.json index e2f6098..f59c55e 100644 --- a/apps/web/i18n/locales/ky.json +++ b/apps/web/i18n/locales/ky.json @@ -1,118 +1,142 @@ { "common": { - "back": "Артка" + "back": "Артка", + "cancel": "Артка", + "all": "Баары", + "monthsShort": ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"], + "monthsLong": ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"], + "datePattern": { "short": "{d}-{m}", "long": "{w}, {d}-{m}" }, + "weekdays": ["Жекшемби", "Дүйшөмбү", "Шейшемби", "Шаршемби", "Бейшемби", "Жума", "Ишемби"], + "weekdaysShort": ["Жек", "Дүй", "Шей", "Шар", "Бей", "Жум", "Ише"], + "nights": { "one": "түн", "few": "түн", "many": "түн" }, + "free": { "one": "бош", "few": "бош", "many": "бош" } }, "nav": { "today": "Бүгүн", - "calendar": "Календарь", - "guests": "Коноктор", - "more": "Дагы", - "rooms": "Бөлмөлөр" + "occupancy": "Бош эместик", + "queue": "Кезек" }, "sync": { - "pending": "Синхрондоо… ({count})", - "offline": "Офлайн", - "offlineFull": "Офлайн — маалымат телефондо сакталды", - "synced": "Синхрондолду" + "online": "Байланыш бар", + "offline": "Байланыш жок", + "allSent": "Баары жөнөтүлдү", + "inQueue": "{n} кезекте", + "notSentCount": "{n} жөнөтүлгөн жок", + "notSent": "Жөнөтүлгөн жок", + "sending": "Жөнөтүлүүдө…", + "rejected": "Сервер четке какты", + "conflict": "Версия карама-каршылыгы" }, "login": { "title": "Кирүү", - "subtitle": "Конок үйлөр үчүн санарип дептер", + "subtitle": "Конок үйлөр үчүн санариптик дептер", "phone": "Телефон", "name": "Аты", "sendCode": "Код алуу", - "code": "SMS-код", + "code": "SMS коду", "verify": "Кирүү", "changePhone": "Номерди өзгөртүү", - "devCodeHint": "Демо-режим, код: {code}", - "error": "Кире алган жок. Маалыматты текшериңиз." + "devCodeHint": "Демо режим, код: {code}", + "error": "Кирүү мүмкүн болгон жок. Маалыматты текшериңиз." }, "today": { - "title": "Бүгүн", - "requests": "Боттон келген табыштамалар", - "arrivals": "Келүүлөр", - "departures": "Кетүүлөр", - "free": "Бүгүн бош: {total} ичинен {free}", - "emptyArrivals": "Бүгүн келүүлөр жок", - "emptyDepartures": "Бүгүн кетүүлөр жок", - "emptyHint": "Бронь кошуңуз — «+ Бронь» баскычын басыңыз же календардан бош күндү тандаңыз", - "toCalendar": "Календарды ачуу", - "checkIn": "Киргизүү", - "checkOut": "Чыгаруу", - "addBooking": "Бронь" + "arrivals": "Кирүү", + "departures": "Чыгуу", + "waiting": "Күтүүдө", + "priority": "Алгач", + "nothing": "Бүгүнкү иштер бүттү", + "newBooking": "Жаңы брондоо", + "roomsCount": "{n} бөлмө", + "loadFailed": "Брондоолорду жүктөө мүмкүн болгон жок. Телефондо сакталганы көрсөтүлдү." }, "booking": { - "guestName": "Коноктун аты", - "guestPhone": "Коноктун телефону", - "checkIn": "Келүү", - "checkOut": "Кетүү", - "room": "Бөлмө", - "nights": "Түн: {n}", - "create": "Бронду сактоо", "confirm": "Ырастоо", - "reject": "Четке кагуу", - "cancel": "Жокко чыгаруу", - "overbooking": "Бул күндөр бош эмес — кош бронь мүмкүн эмес", + "checkIn": "Жайгаштыруу", + "checkOut": "Чыгаруу", + "cancelBooking": "Брондоону жокко чыгаруу", + "fromTelegram": "Telegram", + "checkOutQuestion": "{guest} чыгарылсынбы?", + "checkOutHint": "{room}-бөлмө бүгүнкү түндөн бош болот", + "confirmWithGuest": "Ырастоо · {guest}", + "andMore": "+{n}", "status": { "HOLD": "Күтүүдө", - "CONFIRMED": "Ырасталды", - "CHECKED_IN": "Конок келди", - "CHECKED_OUT": "Кетти", - "CANCELLED": "Жокко чыгарылды" + "CONFIRMED": "Ырасталган", + "CHECKED_IN": "Үйдө", + "CHECKED_OUT": "Чыккан", + "CANCELLED": "Жокко чыгарылган" } }, - "calendar": { - "title": "Календарь", - "newBooking": "Жаңы бронь", - "free": "бош", - "today": "Бүгүн", - "hint": "Азырынча брондор жок. Биринчисин кошуу үчүн бош күндү басыңыз", - "legendFree": "Бош", - "legendHold": "Күтүүдө", - "legendConfirmed": "Ырасталды", - "legendCheckedIn": "Конок келди" + "occupancy": { + "day": "Күн", + "nights": "Түндөр", + "grid": "Тор", + "free": "Бош", + "freeOf": "{free} / {total}", + "fullBooked": "Баары бош эмес, {total}/{total}", + "noFree": "Бош бөлмө жок", + "turn": "Ирет", + "turnNote": "«Ирет» — конок чыгып, ошол эле күнү кийинкиси кирген. Бөлмө эки жолу ээленген эмес.", + "nightOf": "{n} түндүн {i}-и", + "arrival": "Кирүү", + "departure": "Чыгуу", + "leftToday": "чыкты: {guest}", + "legend": "Тилкенин абалы:", + "gridNote": "Тилке кирүү күнүнүн ортосунан башталат: чыгуу жана кирүү бир күндө түйүшөт, кайчылашпайт.", + "prevDay": "Мурунку күн", + "nextDay": "Кийинки күн" }, - "rooms": { - "title": "Бөлмөлөр жана баалар", - "types": "Бөлмө түрлөрү", - "addType": "Түр кошуу", - "typeName": "Түрдүн аталышы", - "typeNamePh": "мис. Стандарт 2 орундуу", - "capacity": "Орун", - "basePrice": "Бир түнгө баа (сом)", - "list": "Бөлмөлөр", - "addRoom": "Бөлмө кошуу", - "label": "Бөлмө", - "labelPh": "мис. №101", - "selectType": "Бөлмө түрү", - "emptyTypes": "Адегенде бөлмө түрүн кошуңуз — форма төмөндө", - "emptyRooms": "Эми бөлмөлөрдү кошуңуз — форма төмөндө", - "save": "Сактоо", - "delete": "Өчүрүү" + "card": { + "room": "Бөлмө", + "dates": "Кирүү → чыгуу", + "nights": "Түн", + "total": "Төлөнөт, сом", + "documents": "Документтер", + "documentsUnknown": "Маалымат жок", + "mark": "Белгилөө", + "blockedDocuments": "Документтин белгисин сактай турган жер жок: брондоодо да, конокто да мындай талаа жок. API оңдоону талап кылат.", + "extend": "Бир түнгө узартуу", + "earlyCheckout": "Бүгүн эрте чыгуу", + "blockedDates": "Күндөрдү өзгөртүү мүмкүн эмес: PATCH /api/bookings/:id жок, сервер статусту гана өзгөртө алат. API оңдоону талап кылат.", + "notSentHint": "Өзгөртүү телефондо сакталды, жөнөтүүнү күтүүдө", + "notFound": "Брондоо жүктөлгөндөрдүн арасынан табылган жок", + "cancelQuestion": "{guest} брондоосу жокко чыгарылсынбы?", + "cancelHint": "Бөлмө бошойт, брондоо бардык тизмелерден чыгат" }, - "guests": { - "title": "Коноктор", - "search": "Аты же телефону боюнча издөө", - "empty": "Азырынча коноктор жок — алар брондордон пайда болот", - "lastBookings": "Акыркы брондор" + "queue": { + "empty": "Кезек бош — баары шайкештирилди", + "needsDecision": "{n} чечим талап кылат", + "statInQueue": "кезекте", + "statBroken": "катасы бар", + "send": "Жөнөтүп көрүү", + "retry": "Кайталоо", + "discard": "Четке кагуу", + "change": "Өзгөртүү", + "mine": "Телефондо", + "theirs": "Серверде", + "changedBy": "Өзгөрттү", + "blockedOtherRoom": "Башка бөлмөгө которуу мүмкүн эмес: PATCH /api/bookings/:id жок. API оңдоону талап кылат.", + "error": { + "OVERBOOKING": "Бөлмө башка брондоо менен ээленген — сервер бирөөнү гана кабыл алды. Өзгөртүү сакталган жок.", + "INVALID_TRANSITION": "Мындай статус өткөрүү тыюу салынган: брондоо башка түзмөктөн өзгөртүлгөн." + } }, - "settings": { - "title": "Дагы", - "language": "Тил", - "property": "Объект", - "propertyName": "Аталышы", - "region": "Аймак", - "save": "Сактоо", - "saved": "Сакталды", - "saveError": "Сактоо мүмкүн болгон жок", - "slug": "Витрина дареги (латын тамгалары)", - "slugHint": "Маркетплейс аркылуу брондоо үчүн ачык барак, мис. my-guest-house", - "slugTaken": "Бул дарек бош эмес", - "slugInvalid": "Дарек: 2–40 белги, a–z, 0–9 жана дефис гана", - "roomsLink": "Бөлмөлөр жана баалар", - "subscription": "Жазылуу", - "planFree": "Акысыз план — эсеп жана анти-овербукинг дайыма акысыз", - "planPro": "Pro активдүү (Telegram-бот жана билдирүүлөр)", - "logout": "Аккаунттан чыгуу" + "create": { + "today": "Бүгүн", + "tomorrow": "Эртең", + "nights": "Түн", + "roomType": "Бөлмөнүн түрү", + "guestName": "Коноктун аты", + "guestNameHint": "Фамилиясы жана аты", + "submit": "Түзүү", + "noFreeHint": "Бул күндөргө бош бөлмө жок. Эң жакыны: {date}", + "noFreeAtAll": "Эки жумада бул түрдөгү бош бөлмө жок", + "error": { + "OVERBOOKING": "Форманы толтуруп жатканда бөлмө ээленди. Башка түрдү же башка күндөрдү тандаңыз.", + "ROOM_NOT_FOUND": "Бөлмө эми жок — бөлмөлөрдүн тизмесин жаңыртыңыз", + "GUEST_REQUIRED": "Коноктун атын жазыңыз", + "NO_FREE_ROOM": "Бул күндөргө тандалган түрдөгү бош бөлмө жок", + "generic": "Брондоо түзүлгөн жок. Кайра аракет кылыңыз." + } } } diff --git a/apps/web/i18n/locales/ru.json b/apps/web/i18n/locales/ru.json index 710684e..7c53ddb 100644 --- a/apps/web/i18n/locales/ru.json +++ b/apps/web/i18n/locales/ru.json @@ -1,19 +1,31 @@ { "common": { - "back": "Назад" + "back": "Назад", + "cancel": "Отмена", + "all": "Все", + "monthsShort": ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"], + "monthsLong": ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"], + "datePattern": { "short": "{d} {m}", "long": "{w}, {d} {m}" }, + "weekdays": ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"], + "weekdaysShort": ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], + "nights": { "one": "ночь", "few": "ночи", "many": "ночей" }, + "free": { "one": "свободен", "few": "свободно", "many": "свободных" } }, "nav": { "today": "Сегодня", - "calendar": "Календарь", - "guests": "Гости", - "more": "Ещё", - "rooms": "Номера" + "occupancy": "Занятость", + "queue": "Очередь" }, "sync": { - "pending": "Синхронизация… ({count})", - "offline": "Офлайн", - "offlineFull": "Офлайн — данные сохранены на телефоне", - "synced": "Синхронизировано" + "online": "Сеть есть", + "offline": "Нет сети", + "allSent": "Всё отправлено", + "inQueue": "{n} в очереди", + "notSentCount": "{n} не отправлено", + "notSent": "Не отправлено", + "sending": "Отправка…", + "rejected": "Отказ сервера", + "conflict": "Конфликт версии" }, "login": { "title": "Вход", @@ -28,91 +40,245 @@ "error": "Не удалось войти. Проверьте данные." }, "today": { - "title": "Сегодня", - "requests": "Заявки из бота", "arrivals": "Заезды", "departures": "Выезды", - "free": "Свободно сегодня: {free} из {total}", - "emptyArrivals": "Заездов сегодня нет", - "emptyDepartures": "Выездов сегодня нет", - "emptyHint": "Добавьте бронь — нажмите «+ Бронь» или выберите свободный день в календаре", - "toCalendar": "Открыть календарь", - "checkIn": "Заселить", - "checkOut": "Выселить", - "addBooking": "Бронь" + "waiting": "Ждут", + "priority": "Сначала", + "nothing": "На сегодня всё сделано", + "newBooking": "Новая бронь", + "roomsCount": "{n} номеров", + "loadFailed": "Не удалось загрузить брони. Показано то, что сохранено на телефоне." }, "booking": { - "guestName": "Имя гостя", - "guestPhone": "Телефон гостя", - "checkIn": "Заезд", - "checkOut": "Выезд", - "room": "Номер", - "nights": "Ночей: {n}", - "create": "Сохранить бронь", "confirm": "Подтвердить", - "reject": "Отклонить", - "cancel": "Отмена", - "overbooking": "Эти даты уже заняты — двойная бронь невозможна", + "checkIn": "Заселить", + "checkOut": "Выселить", + "cancelBooking": "Отменить бронь", + "fromTelegram": "Telegram", + "checkOutQuestion": "Выселить {guest}?", + "checkOutHint": "Номер {room} станет свободным с сегодняшней ночи", + "confirmWithGuest": "Подтвердить · {guest}", + "andMore": "+{n}", "status": { "HOLD": "Ожидает", "CONFIRMED": "Подтверждена", - "CHECKED_IN": "Гость заехал", + "CHECKED_IN": "В доме", "CHECKED_OUT": "Выехал", "CANCELLED": "Отменена" } }, - "calendar": { - "title": "Календарь", - "newBooking": "Новая бронь", - "free": "свободно", - "today": "Сегодня", - "hint": "Пока нет броней. Нажмите на свободный день, чтобы добавить первую", - "legendFree": "Свободно", - "legendHold": "Ожидает", - "legendConfirmed": "Подтверждена", - "legendCheckedIn": "Гость заехал" + "occupancy": { + "day": "День", + "nights": "Ночи", + "grid": "Сетка", + "free": "Свободно", + "freeOf": "{free} / {total}", + "fullBooked": "Занято всё, {total} из {total}", + "noFree": "Свободных номеров нет", + "turn": "Стык", + "turnNote": "«Стык» — гость выехал и в тот же день заехал следующий. Номер не занят дважды.", + "nightOf": "ночь {i} из {n}", + "arrival": "Заезд", + "departure": "Выезд", + "leftToday": "выехал: {guest}", + "legend": "Статус полосы:", + "gridNote": "Полоса начинается в середине дня заезда: выезд и заезд в один день стыкуются, а не пересекаются.", + "prevDay": "Предыдущий день", + "nextDay": "Следующий день" }, - "rooms": { - "title": "Номера и цены", - "types": "Типы номеров", - "addType": "Добавить тип", - "typeName": "Название типа", - "typeNamePh": "напр. Стандарт 2-местный", - "capacity": "Мест", - "basePrice": "Цена за ночь (сом)", - "list": "Номера", - "addRoom": "Добавить номер", - "label": "Номер", - "labelPh": "напр. №101", - "selectType": "Тип номера", - "emptyTypes": "Сначала добавьте тип номера — форма ниже", - "emptyRooms": "Теперь добавьте номера — форма ниже", - "save": "Сохранить", - "delete": "Удалить" + "card": { + "room": "Номер", + "dates": "Заезд → выезд", + "nights": "Ночей", + "total": "К оплате, сом", + "documents": "Документы", + "documentsUnknown": "Нет данных", + "mark": "Отметить", + "blockedDocuments": "Отметку о документах хранить негде: поля нет ни у брони, ни у гостя. Требует правки API.", + "extend": "Продлить на 1 ночь", + "earlyCheckout": "Ранний выезд сегодня", + "blockedDates": "Даты изменить нечем: PATCH /api/bookings/:id не существует, сервер умеет только смену статуса. Требует правки API.", + "notSentHint": "Изменение сохранено на телефоне и ждёт отправки", + "notFound": "Бронь не найдена среди загруженных", + "cancelQuestion": "Отменить бронь · {guest}?", + "cancelHint": "Номер освободится, и бронь уйдёт из всех списков" }, - "guests": { - "title": "Гости", - "search": "Поиск по имени или телефону", - "empty": "Гостей пока нет — они появятся из броней", - "lastBookings": "Последние брони" + "queue": { + "empty": "Очередь пуста — всё синхронизировано", + "needsDecision": "{n} требует решения", + "statInQueue": "в очереди", + "statBroken": "с ошибкой", + "send": "Попробовать отправить", + "retry": "Повторить", + "discard": "Отклонить", + "change": "Изменение", + "mine": "На телефоне", + "theirs": "На сервере", + "changedBy": "Изменил", + "blockedOtherRoom": "Переселить в другой номер нечем: PATCH /api/bookings/:id не существует. Требует правки API.", + "error": { + "OVERBOOKING": "Номер уже занят другой бронью — сервер принял только одну. Изменение не сохранено.", + "INVALID_TRANSITION": "Такой переход статуса запрещён: бронь успели изменить с другого устройства." + } }, - "settings": { - "title": "Ещё", - "language": "Язык", - "property": "Объект", - "propertyName": "Название", - "region": "Регион", - "save": "Сохранить", - "saved": "Сохранено", - "saveError": "Не удалось сохранить", - "slug": "Адрес витрины (латиницей)", - "slugHint": "Публичная страница для брони с маркетплейса, напр. my-guest-house", - "slugTaken": "Этот адрес уже занят", - "slugInvalid": "Адрес: 2–40 символов, только a–z, 0–9 и дефис", - "roomsLink": "Номера и цены", - "subscription": "Подписка", - "planFree": "Бесплатный план — учёт и анти-овербукинг всегда бесплатны", - "planPro": "Pro активен (Telegram-бот и уведомления)", - "logout": "Выйти из аккаунта" + "create": { + "today": "Сегодня", + "tomorrow": "Завтра", + "nights": "Ночей", + "roomType": "Тип номера", + "guestName": "Имя гостя", + "guestNameHint": "Фамилия и имя", + "submit": "Создать", + "noFreeHint": "На эти даты свободных номеров нет. Ближайшие: {date}", + "noFreeAtAll": "Свободных номеров этого типа нет на ближайшие две недели", + "error": { + "OVERBOOKING": "Номер заняли, пока вы заполняли форму. Выберите другой тип или другие даты.", + "ROOM_NOT_FOUND": "Номер больше не существует — обновите список номеров", + "GUEST_REQUIRED": "Укажите имя гостя", + "NO_FREE_ROOM": "На эти даты свободных номеров выбранного типа нет", + "generic": "Не удалось создать бронь. Попробуйте ещё раз." + } + }, + "gov": { + "agency": "КЫРГЫЗ РЕСПУБЛИКАСЫНЫН МИНИСТРЛЕР КАБИНЕТИНЕ КАРАШТУУ ТУРИЗМДИ ӨНҮКТҮРҮҮ БОЮНЧА МАМЛЕКЕТТИК АГЕНТТИК", + "title": "Средства размещения: что страна знает и чего не знает", + "off": { + "title": "Часть 1. Что известно сегодня", + "lead": "Опубликованные показатели. Это НЕ данные системы — это официальная статистика, приведённая здесь для сравнения. Проверено {date} по разделу «Открытые данные» Нацстаткома КР.", + "srcNsc": "Нацстатком КР, открытые данные", + "srcPress": "публикации СМИ, первоисточник не сверялся", + "pressNote": "Показатели, помеченные как публикации СМИ, приведены со ссылкой на ведомственные сообщения и требуют сверки с первоисточником. Ряды Нацстаткома — машиночитаемые, с указанием года выпуска.", + "gva": "Валовая добавленная стоимость туризма", + "gdpShare": "Доля туризма в ВВП", + "investment": "Инвестиции в основной капитал туризма", + "visitors": "Число посетителей", + "horeca": "Услуги гостиниц и ресторанов", + "lodging": "в том числе услуги проживания", + "sanatoria": "Санатории и учреждения отдыха", + "hotels": "Гостиниц / пансионатов / санаториев", + "ikObjects": "Объектов размещения, Иссык-Куль", + "ikGuests": "Отдыхающих, Иссык-Куль", + "gvaChart": "Валовая добавленная стоимость туризма, млн сом", + "gvaNote": "Нацстатком КР, открытые данные. Рост за пять лет — в 3,1 раза", + "unit": { + "mln": "млн сом", + "pct": "%", + "thousand": "тыс. чел.", + "units": "единиц", + "mlnPeople": "млн чел." + } + }, + "gap": { + "title": "Часть 2. Чего нет ни у кого", + "lead": "Проверено {date} по всем разделам открытых данных Нацстаткома. Ни один из перечисленных показателей не публикуется — ни по стране, ни по областям.", + "punch": "Государство считает поток и деньги: сколько приехало и сколько потратили. Предложение оно не считает, потому что собрать его можно только снизу, с каждого объекта. Поэтому на вопрос «какая была загрузка Иссык-Куля в июле» сегодня в стране не может ответить никто — не потому что не хотят, а потому что нет величины в знаменателе.", + "rooms": "Номерной фонд", + "roomsWhy": "сколько всего номеров в стране — ни одного опубликованного ряда, ни по стране, ни по областям", + "supply": "Предложение, номеро-сутки", + "supplyWhy": "сколько номеро-суток фактически выставлено к продаже — не собирается", + "sold": "Продано, номеро-суток", + "soldWhy": "считается число обслуженных лиц, но не проданные номеро-сутки", + "occupancy": "Загрузка", + "occupancyWhy": "невозможно вычислить: нет знаменателя. Ни один отчёт страны её не содержит", + "adr": "ADR и RevPAR", + "adrWhy": "выручка публикуется общей суммой, разделить её на номеро-сутки нечем", + "district": "Разбивка по районам", + "districtWhy": "ряд «услуги гостиниц и ресторанов по территории» обрывается на 2017 году и приведён в сомах, а не в номерах" + }, + "nc": { + "title": "Часть 3. Что даёт NomadCore", + "lead": "Те же четыре показателя, которых нет выше, — измеренные, а не оценённые. Они появляются как побочный продукт работы владельца: объект, который ведёт учёт, автоматически сообщает, сколько номеро-суток выставил и сколько продал. Ниже — как это выглядит на пилотном контуре." + }, + "period": "Период:", + "exported": "Выгрузка:", + "status": "Статус:", + "synthetic": "Синтетические данные", + "syntheticNote": "Числа сгенерированы для проектирования интерфейса и не являются официальной статистикой. Структура показателей и порядок величин соответствуют реальной отчётности; сами значения — нет.", + "byRegion": "Разрез 1. По регионам", + "byCategory": "Разрез 2. По категориям классификации", + "totalRow": "Итого", + "bedNights": "Койко-сутки⁷ — {n}. Оценка, а не измеренная величина.", + "col": { + "region": "Регион", + "category": "Категория", + "objects": "Объектов", + "rooms": "Номеров", + "daysOpen": "Дней в работе¹", + "supply": "Предложение², н-сут.", + "sold": "Продано, н-сут.", + "soldPrev": "Продано 2025", + "occupancy": "Загрузка³, %", + "checkins": "Заездов", + "avgStay": "Ср. прож.⁴", + "adr": "ADR⁵, сом", + "yoy": "Δ к 2025⁶", + "share": "Доля продаж, %" + }, + "seasonTitle": "Разрез 3. Продано по месяцам, номеро-суток", + "seasonSum": "Сумма столбцов {sum} = «Продано» в таблице {total}", + "checksTitle": "Сверки", + "checksLead": "Считаются на этом экране из тех же величин, что напечатаны выше. Любую можно проверить калькулятором. Расхождение двух разрезов означает ошибку выгрузки и видно сразу, а не через квартал.", + "occupancyFormula": "Загрузка по республике = {sold} ÷ {supply} = {pct} %", + "rec": { + "objects": "Объектов: по регионам = по категориям", + "rooms": "Номеров: по регионам = по категориям", + "supply": "Предложение: по регионам = по категориям", + "sold": "Продано: по регионам = по категориям", + "season": "Продано: сумма по месяцам = итог таблицы" + }, + "coverageTitle": "Охват", + "coverageLead": "Показатели считаются только по объектам, подключённым к системе, и поэтому ЗАНИЖЕНЫ на величину неохваченного фонда. Долю охвата нужно называть вместе с цифрой.", + "note": { + "daysOpen": "¹ Дней в работе — предложение ÷ номера, округлённое. Величина справочная.", + "supply": "² Предложение — номеро-сутки, фактически выставленные к продаже. Объект вне сезона закрыт и в знаменатель не входит; календарный период знаменателем не является.", + "occupancy": "³ Загрузка = продано ÷ предложение. Не хранится: вычисляется из двух соседних столбцов.", + "avgStay": "⁴ Средняя продолжительность = продано ÷ заездов.", + "adr": "⁵ ADR = выручка ÷ продано, сом за номеро-сутки.", + "yoy": "⁶ Δ к 2025 = продано 2026 ÷ продано 2025 − 1.", + "bedNights": "⁷ Койко-сутки = продано × 2,3 чел. на номер. Прямого учёта спящих нет, поэтому величина оценочная и вынесена отдельно от загрузки." + }, + "drilldown": "Разрез по районам: {region} →", + "d": { + "title": "Иссык-Кульская область: районы, охват и кандидат на пилот", + "back": "← К разрезу по регионам", + "tableTitle": "Районы области. Сортировка по разрыву — числу объектов, которых ещё нет в системе", + "totalRow": "Итого по области", + "col": { + "district": "Район", + "connected": "Подключено", + "fund": "Фонд, оценка¹", + "coverage": "Охват², %", + "gap": "Разрыв³", + "supply": "Предложение⁴, н-сут.", + "occupancy": "Загрузка⁵, %", + "adr": "ADR⁶, сом", + "yoy": "Δ к 2025⁷" + }, + "pilotTitle": "Кандидат на пилот", + "pilotLead": "{gap} объектов из {fund} ещё не в системе. Охват района — {coverage} %, самый низкий по области при значимом размере фонда.", + "pilotWhy": "Район выбран по ЧИСЛУ неподключённых объектов, а не по низшему проценту: 40 % охвата при фонде в двадцать домов — это восемь домов работы, а не пилот. Величина считается на этом экране и меняется вместе с данными.", + "pilotTarget": "Цель сезона: подключить {n} объектов и поднять охват района с {from} % до {to} %.", + "checksTitle": "Сверки с разрезом по регионам", + "checksLead": "Суммы по районам сравниваются со строкой «Иссык-Кульская область» из общего экрана — не с копией чисел, а с тем же источником. Расхождение означало бы, что два разреза разъехались.", + "coverageFormula": "Охват области = {connected} ÷ {fund} = {pct} %", + "rec": { + "objects": "Объектов: сумма по районам = строка области", + "rooms": "Номеров: сумма по районам = строка области", + "supply": "Предложение: сумма по районам = строка области", + "sold": "Продано: сумма по районам = строка области", + "soldPrev": "Продано 2025: сумма по районам = строка области", + "checkins": "Заездов: сумма по районам = строка области", + "revenue": "Выручка: сумма по районам = строка области" + }, + "note": { + "fund": "¹ Фонд — оценка ВСЕГО числа средств размещения района, включая неподключённые. Источник: реестр классификации и полевой обход. Единственная величина на экране, которую система не измеряет.", + "coverage": "² Охват = подключено ÷ фонд. Все остальные показатели района считаются только по подключённым объектам и поэтому занижены на величину разрыва.", + "gap": "³ Разрыв = фонд − подключено. Это объём работы, а не показатель качества района.", + "supply": "⁴ Предложение — номеро-сутки, фактически выставленные к продаже. Объект вне сезона закрыт и в знаменатель не входит.", + "occupancy": "⁵ Загрузка = продано ÷ предложение. Не хранится: вычисляется из двух соседних столбцов.", + "adr": "⁶ ADR = выручка ÷ продано, сом за номеро-сутки.", + "yoy": "⁷ Δ к 2025 = продано 2026 ÷ продано 2025 − 1." + } + } } } diff --git a/apps/web/layouts/default.vue b/apps/web/layouts/default.vue index a83a900..26339ef 100644 --- a/apps/web/layouts/default.vue +++ b/apps/web/layouts/default.vue @@ -1,60 +1,48 @@ + + diff --git a/apps/web/layouts/desk.vue b/apps/web/layouts/desk.vue new file mode 100644 index 0000000..92b5f66 --- /dev/null +++ b/apps/web/layouts/desk.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/apps/web/middleware/auth.global.ts b/apps/web/middleware/auth.global.ts index b61b918..49209ad 100644 --- a/apps/web/middleware/auth.global.ts +++ b/apps/web/middleware/auth.global.ts @@ -4,6 +4,11 @@ * чтобы setup-код страниц не дергал API без токена. */ export default defineNuxtRouteMiddleware((to) => { + // В демо-режиме бэкенда рядом нет, токен взять неоткуда, и гвард + // заворачивал бы все четыре экрана на /login. Проверка живёт здесь, + // а не в каждой странице: гвард глобальный, обходить его точечно хуже. + if (useRuntimeConfig().public.demo) return + const { isAuthed } = useSession() if (!isAuthed.value && to.path !== "/login") { return navigateTo("/login") diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 18d2269..40ab8b5 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -4,10 +4,48 @@ export default defineNuxtConfig({ // Offline-first PWA: рендер только на клиенте. // Dexie/IndexedDB недоступны в Node, поэтому SSR приводил к ошибкам dev-сервера. ssr: false, - modules: ["@nuxtjs/i18n", "@vite-pwa/nuxt"], + modules: ["@nuxtjs/i18n", "@nuxtjs/tailwindcss", "@vite-pwa/nuxt"], + + // Токены подключаются ПЕРВЫМИ, утилиты Tailwind — после них, иначе базовые + // правила tokens.css перекрывали бы утилиты, а не наоборот. + css: ["~/design-system/tokens.css"], + tailwindcss: { cssPath: "~/assets/css/tailwind.css" }, + + // Примитивы дизайн-системы регистрируются глобально без префикса пути: + // в шаблонах они пишутся как , а не . + // Массив ЗАМЕНЯЕТ умолчание, поэтому ~/components перечислен явно. + components: [ + { path: "~/design-system/primitives", pathPrefix: false }, + { path: "~/components", pathPrefix: false }, + ], + + app: { + head: { + htmlAttrs: { lang: "ru" }, + meta: [ + { + name: "viewport", + content: + "width=device-width, initial-scale=1, viewport-fit=cover", + }, + ], + }, + }, + runtimeConfig: { public: { apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "http://localhost:3001/api", + // Демо-режим: слой данных берёт fixtures/demo.ts вместо живого API. + // Включён по умолчанию, пока рядом нет поднятого бэкенда. + // Выключение: NUXT_PUBLIC_DEMO=0 + demo: process.env.NUXT_PUBLIC_DEMO !== "0", + // Показывать ли блоки, которые нечем реализовать против текущего + // API: «Документы», «Продлить», «Ранний выезд», «Другой номер». + // ВЫКЛЮЧЕНО по умолчанию: владелец гостевого дома не должен читать + // «PATCH /api/bookings/:id не существует» — он прочтёт это как + // поломку. Включается для разбора: NUXT_PUBLIC_SHOW_API_GAPS=1 + // Список пробелов в любом случае живёт в docs/WEB-API-GAPS.md. + showApiGaps: process.env.NUXT_PUBLIC_SHOW_API_GAPS === "1", }, }, i18n: { @@ -17,6 +55,8 @@ export default defineNuxtConfig({ { code: "en", name: "English", file: "en.json" }, ], defaultLocale: "ru", + // fallbackLocale — опция vue-i18n, а не модуля, поэтому живёт + // в i18n/i18n.config.ts. Здесь её указывать нельзя: TS2353. langDir: "locales", strategy: "no_prefix", // Сохраняем выбор языка между сессиями (cookie), иначе setLocale слетает при перезагрузке @@ -33,8 +73,11 @@ export default defineNuxtConfig({ name: "NomadCore — Тетрадка", short_name: "NomadCore", description: "Цифровая тетрадка для гостевых домов КР", - theme_color: "#1c4532", - background_color: "#ffffff", + // Единственное место во фронте, где цвет записан литералом: + // манифест — это статический JSON, var(--nc-*) он не читает. + // Значения = --nc-action-primary-bg и --nc-bg-canvas светлой темы. + theme_color: "#111310", + background_color: "#EDEDEA", display: "standalone", lang: "ru", icons: [ diff --git a/apps/web/offline/db.ts b/apps/web/offline/db.ts index 3d7ed98..b97ad35 100644 --- a/apps/web/offline/db.ts +++ b/apps/web/offline/db.ts @@ -7,12 +7,51 @@ export type CachedRead = { updatedAt: number } +/** + * Состояние мутации в очереди. + * + * pending — ждёт сети, уйдёт сама; + * conflict — сервер вернул 409 с телом: есть обе версии, решает владелец; + * rejected — сервер отказал по существу (овербукинг, запрещённый переход), + * выбирать нечего, нужно другое действие. + * + * conflict и rejected сами НЕ отправляются никогда и блокируют только свою + * бронь — изменения по другим броням продолжают уходить. Обоснование: + * nuxt/API-CONFLICTS.md, раздел «Что происходит с очередью». + */ +export type MutationState = "pending" | "conflict" | "rejected" + +/** Снимок сервера при 409. Форма — из nuxt/API-CONFLICTS.md. */ +export type ServerSnapshot = { + booking?: unknown + changedBy?: string + changedAt?: string + changedFields?: string[] + code?: string + message?: string +} + export type PendingMutation = { id?: number path: string method: "POST" | "PATCH" | "DELETE" body: unknown createdAt: number + /* ── добавлено в версии 2 ─────────────────────────────────────────── + Все поля НЕОБЯЗАТЕЛЬНЫЕ: строки, записанные схемой v1, остаются + валидными, и код обязан читать отсутствие state как "pending". */ + /** Бронь, к которой относится мутация. Нужна, чтобы блокировать одну + * бронь, а не всю очередь. */ + bookingId?: string + state?: MutationState + /** Версия, на которой мутация построена. Уйдёт в If-Match, когда API + * научится его принимать. Сейчас сервер её не читает. */ + baseVersion?: number + attempts?: number + /** Код или текст отказа сервера — то, что показывается владельцу. */ + error?: string + /** Снимок сервера при 409. Без него колонку «На сервере» нечем заполнить. */ + server?: ServerSnapshot } export type SyncConflict = { @@ -33,3 +72,30 @@ db.version(1).stores({ pendingMutations: "++id, createdAt", conflicts: "++id, createdAt", }) + +/** + * Версия 2 — только НОВЫЕ индексы и новые необязательные поля. + * Ни одна таблица не удалена, ни один индекс не изменён, данные из v1 + * переносятся как есть: апгрейд лишь проставляет умолчания там, где + * старые строки их не имели. + */ +db.version(2) + .stores({ + cachedReads: "key, updatedAt", + pendingMutations: "++id, createdAt, state, bookingId", + conflicts: "++id, createdAt", + }) + .upgrade((tx) => + tx + .table("pendingMutations") + .toCollection() + .modify((m) => { + m.state ??= "pending" + m.attempts ??= 0 + }), + ) + +/** Путь мутации → id брони. Единственное место, где путь разбирается. */ +export function bookingIdFromPath(path: string): string | undefined { + return /^\/bookings\/([^/?]+)/.exec(path)?.[1] +} diff --git a/apps/web/package.json b/apps/web/package.json index ffc1121..6d4dc1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,9 @@ "vue": "^3.5.0" }, "devDependencies": { + "@nuxtjs/tailwindcss": "^6.14.0", "eslint": "^9.0.0", + "tailwindcss": "^3.4.19", "typescript": "^5.6.0", "vue-tsc": "^2.1.0" } diff --git a/apps/web/pages/booking/[id].vue b/apps/web/pages/booking/[id].vue new file mode 100644 index 0000000..a023a97 --- /dev/null +++ b/apps/web/pages/booking/[id].vue @@ -0,0 +1,352 @@ + + + + + diff --git a/apps/web/pages/calendar.vue b/apps/web/pages/calendar.vue deleted file mode 100644 index eddf1bd..0000000 --- a/apps/web/pages/calendar.vue +++ /dev/null @@ -1,288 +0,0 @@ - - - - - diff --git a/apps/web/pages/gov/issyk-kul.vue b/apps/web/pages/gov/issyk-kul.vue new file mode 100644 index 0000000..2517f4b --- /dev/null +++ b/apps/web/pages/gov/issyk-kul.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/apps/web/pages/gov/occupancy.vue b/apps/web/pages/gov/occupancy.vue new file mode 100644 index 0000000..5792243 --- /dev/null +++ b/apps/web/pages/gov/occupancy.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/apps/web/pages/guests.vue b/apps/web/pages/guests.vue deleted file mode 100644 index 8fbc1f5..0000000 --- a/apps/web/pages/guests.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index d0a0aed..31fe1c1 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -1,194 +1,264 @@ diff --git a/apps/web/pages/login.vue b/apps/web/pages/login.vue index 001b65d..a29c0bc 100644 --- a/apps/web/pages/login.vue +++ b/apps/web/pages/login.vue @@ -1,9 +1,23 @@ diff --git a/apps/web/pages/occupancy.vue b/apps/web/pages/occupancy.vue new file mode 100644 index 0000000..f983d77 --- /dev/null +++ b/apps/web/pages/occupancy.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/web/pages/queue.vue b/apps/web/pages/queue.vue new file mode 100644 index 0000000..0f55138 --- /dev/null +++ b/apps/web/pages/queue.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/apps/web/pages/rooms.vue b/apps/web/pages/rooms.vue deleted file mode 100644 index dc2fb6f..0000000 --- a/apps/web/pages/rooms.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - - diff --git a/apps/web/pages/settings.vue b/apps/web/pages/settings.vue deleted file mode 100644 index 8e1ec4e..0000000 --- a/apps/web/pages/settings.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - - diff --git a/apps/web/plugins/sync.client.ts b/apps/web/plugins/sync.client.ts index d104403..ed2db69 100644 --- a/apps/web/plugins/sync.client.ts +++ b/apps/web/plugins/sync.client.ts @@ -1,16 +1,42 @@ -/** При появлении сети — проиграть очередь офлайн-мутаций. */ +/** + * При появлении сети — проиграть очередь офлайн-мутаций. + * + * Единственный вызывающий flushQueue() во всём приложении: без этого плагина + * очередь копится в IndexedDB и не уходит никогда. + * + * Правки только аддитивные: прежний порядок действий сохранён шаг в шаг, + * добавлены отслеживание online и пересчёт счётчика «требует решения». + */ export default defineNuxtPlugin(() => { const { flushQueue } = useApi() const { syncPending } = useSession() + const { online, refresh } = useSyncState() + // В демо-режиме настоящей очереди нет, и Dexie пуст. Без этого гарда + // плагин затирал счётчик демо-очереди нулём, и полоса связи писала + // «Всё отправлено» поверх двух неотправленных мутаций. + const demo = useRuntimeConfig().public.demo async function init() { + online.value = navigator.onLine + if (demo) return const { db } = await import("~/offline/db") syncPending.value = await db.pendingMutations.count() - if (navigator.onLine) await flushQueue() + await refresh() + if (navigator.onLine) { + await flushQueue() + await refresh() + } } window.addEventListener("online", () => { - flushQueue() + online.value = true + if (demo) return + flushQueue().then(refresh) + }) + // Раньше слушателя offline не было вовсе: полоса связи не могла узнать, + // что сеть пропала, пока не случится следующий запрос. + window.addEventListener("offline", () => { + online.value = false }) init() }) diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..6d447e9 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,22 @@ +import type { Config } from "tailwindcss" +import { ncPreset } from "./design-system/tailwind-preset" + +/** + * Литералов цвета и кегля здесь нет и быть не может: единственный источник — + * design-system/tokens.css, который пресет читает через var(--nc-*). + * + * Шкала отступов пресетом ЗАМЕНЕНА, а не расширена: доступны только + * 0 2 4 8 12 16 24 32 48, и имя утилиты равно числу пикселей. Поэтому p-4 + * это 4px, а не 16px как в стоковом Tailwind, а p-3 не существует вовсе. + */ +export default >{ + presets: [ncPreset], + content: [ + "./app.vue", + "./components/**/*.vue", + "./design-system/primitives/**/*.vue", + "./layouts/**/*.vue", + "./pages/**/*.vue", + "./composables/**/*.ts", + ], +} diff --git a/apps/web/test/demo-preflight.mjs b/apps/web/test/demo-preflight.mjs new file mode 100644 index 0000000..38a5b2f --- /dev/null +++ b/apps/web/test/demo-preflight.mjs @@ -0,0 +1,128 @@ +import { chromium } from 'playwright' + +/** + * ПРОГОН СЦЕНАРИЯ ПОКАЗА ПЕРЕД ВСТРЕЧЕЙ. + * + * Каждый шаг docs/DEMO-SCRIPT.md — это утверждение о том, что будет на + * экране: «счётчики 3 / 2 / 1», «нажмите плитку Ждут», «строка номера 3 + * читается как Ким Мария → Chen Wei». Здесь они проверяются по одному. + * + * Смысл прогона: сценарий с неверной инструкцией обходится дороже любого + * бага в коде — ошибку в коде видно вам, ошибку в сценарии видно собеседнику. + * + * ЗАПУСК (playwright намеренно НЕ добавлен в зависимости проекта — + * это разовый инструмент, а не часть сборки): + * + * 1) поднять приложение: cd apps/web && node .output/server/index.mjs + * 2) в другом окне: npx --yes playwright@1.62 install chromium + * 3) node --experimental-strip-types \ + * --import playwright apps/web/test/demo-preflight.mjs + * + * Проще: npm i -D playwright в любой временной папке и запустить оттуда + * с переменной BASE, если приложение поднято не на 3000: + * BASE=http://localhost:3010 node demo-preflight.mjs + * + * Выход 0 — все шаги сценария сходятся, можно идти. + * Выход 1 — список несошедшихся шагов напечатан. + */ +const BASE = process.env.BASE ?? 'http://localhost:3000' +const browser = await chromium.launch() +const results = [] + +const check = (step, ok, detail = '') => { + results.push({ step, ok, detail }) + console.log(`${ok ? 'ok ' : 'FAIL'} ${step}${detail ? ' — ' + detail : ''}`) +} + +// ── Шаги 3.1–3.3: телефон ────────────────────────────────────────────── +const phone = await browser.newContext({ viewport: { width: 360, height: 780 } }) +const p = await phone.newPage() + +await p.goto(BASE + '/', { waitUntil: 'networkidle' }) +const tiles = await p.locator('.tile__n').allTextContents() +check('3.1 счётчики «3 / 2 / 1» на «Сегодня»', tiles.join('/') === '3/2/1', 'фактически ' + tiles.join('/')) + +const primary = (await p.locator('.foot button').first().textContent())?.replace(/\s+/g, ' ').trim() ?? '' +check('3.1 нижняя кнопка «Подтвердить · Петров Игорь»', primary.includes('Подтвердить') && primary.includes('Петров Игорь'), primary) + +const before = await p.locator('.swipe').count() +await p.locator('.tile', { hasText: 'Ждут' }).click() +await p.waitForTimeout(300) +const after = await p.locator('.swipe').count() +check('3.1 плитка «Ждут» фильтрует список', after < before, `${before} → ${after} строк`) +await p.locator('.tile', { hasText: 'Ждут' }).click() +await p.waitForTimeout(300) +check('3.1 повторное нажатие снимает фильтр', (await p.locator('.swipe').count()) === before) + +check('3.3 кнопка «КЫР» в шапке', await p.locator('header button', { hasText: 'КЫР' }).count() > 0) + +await p.goto(BASE + '/occupancy', { waitUntil: 'networkidle' }) +await p.evaluate(() => localStorage.setItem('nc.occupancyMode', 'day')) +await p.reload({ waitUntil: 'networkidle' }) +await p.waitForTimeout(300) +const dayText = (await p.locator('.row').allTextContents()).join(' | ').replace(/\s+/g, ' ') +check('3.2 стык «Ким Мария → Chen Wei» в режиме «День»', /Ким Мария → Chen Wei/.test(dayText)) +check('3.2 метка «Стык» в той же строке', /Ким Мария → Chen Wei[^|]*Стык/.test(dayText)) +check('3.2 пояснение стыка стоит ОДИН раз сверху', (await p.locator('.turnnote').count()) === 1) + +await p.evaluate(() => localStorage.setItem('nc.occupancyMode', 'grid')) +await p.reload({ waitUntil: 'networkidle' }) +await p.waitForTimeout(300) +check('3.2 «Сетка» рисует полосы', (await p.locator('.bar').count()) > 0, (await p.locator('.bar').count()) + ' полос') + +await p.goto(BASE + '/queue', { waitUntil: 'networkidle' }) +const queueText = (await p.locator('main').textContent())?.replace(/\s+/g, ' ') ?? '' +check('3.3 «Очередь» показывает отказ сервера', /Отказ сервера/.test(queueText)) +check('3.3 отказ объяснён словами, а не кодом', /Номер уже занят/.test(queueText), '') +await phone.close() + +// ── Шаги 1–2, 4–5: кабинет ───────────────────────────────────────────── +const desk = await browser.newContext({ viewport: { width: 1440, height: 1200 } }) +const d = await desk.newPage() + +await d.goto(BASE + '/gov/occupancy', { waitUntil: 'networkidle' }) +const govText = (await d.locator('article').textContent())?.replace(/\s+/g, ' ') ?? '' + +check('1. ВДС 58 237,8 на экране', govText.includes('58 237,8')) +check('1. доля в ВВП 3,8', govText.includes('3,8')) +check('1. услуги гостиниц 73 275,2', govText.includes('73 275,2')) +check('1. источники подписаны у каждой цифры', (await d.locator('.fig__src').count()) === (await d.locator('.fig').count())) +check('1. пресса помечена отдельно от Нацстаткома', /публикации СМИ/.test(govText) && /Нацстатком/.test(govText)) +check('1. график ВДС — пять столбцов', (await d.locator('.chart').first().locator('.chart__bar').count()) === 5) + +check('2. перечень пробелов на месте', (await d.locator('.gap').count()) === 6) +check('2. сказано, что загрузку вычислить невозможно', /невозможно вычислить/.test(govText)) + +const synAfter = await d.evaluate(() => { + const off = document.querySelector('.sec') + const syn = document.querySelector('.synthetic') + return !!(off.compareDocumentPosition(syn) & Node.DOCUMENT_POSITION_FOLLOWING) +}) +check('КРИТИЧНО: полоса «синтетические» НИЖЕ официальной части', synAfter) + +check('4. блок сверок с галочками', (await d.locator('.checks__item').count()) === 5) +const marks = await d.locator('.checks__mark').allTextContents() +check('4. все сверки проходят', marks.every((m) => m.trim() === '✓'), marks.join('')) +check('4. формула загрузки напечатана', /378 200 ÷ 1 803 380 = 21,0/.test(govText)) +check('4. блок «Охват» на месте', /Охват/.test(govText) && govText.includes('61 %')) + +const link = d.locator('.drill a') +check('5. ссылка «Разрез по районам» есть', (await link.count()) === 1) +await link.click() +await d.waitForURL('**/gov/issyk-kul') +await d.waitForTimeout(400) +const distText = (await d.locator('article').textContent())?.replace(/\s+/g, ' ') ?? '' +check('5. переход открыл экран районов', d.url().includes('/gov/issyk-kul')) +check('5. кандидат — Тонский район (Боконбаево)', /Тонский район \(Боконбаево\)/.test(distText)) +check('5. названы 94 объекта из 168', /94 объектов из 168/.test(distText)) +check('5. «Цель сезона: подключить 47»', /Цель сезона: подключить 47/.test(distText)) +check('5. охват области 61,0 %', /612 ÷ 1 003 = 61,0/.test(distText)) +await desk.close() + +await browser.close() + +const failed = results.filter((r) => !r.ok) +console.log('\n──────────────────────────────────────────') +console.log(failed.length ? `ШАГОВ НЕ ПРОШЛО: ${failed.length} из ${results.length}` : `ВСЕ ${results.length} ШАГОВ СЦЕНАРИЯ ПРОВЕРЕНЫ`) +failed.forEach((f) => console.log(' · ' + f.step)) +process.exit(failed.length ? 1 : 0) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..7bb22fd --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + // Раньше этого файла не было вовсе, и `nuxt typecheck` падал с + // «Cannot find matching tsconfig.json» — то есть команда из CI + // для apps/web не отрабатывала ни разу. + "extends": "./.nuxt/tsconfig.json", + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/apps/web/types/index.ts b/apps/web/types/index.ts new file mode 100644 index 0000000..32bd258 --- /dev/null +++ b/apps/web/types/index.ts @@ -0,0 +1,146 @@ +/** + * Типы фронта. Форма списана с РЕАЛЬНЫХ ответов apps/api, а не с прототипа: + * поле, которого сервер не отдаёт, здесь заводить нельзя — иначе экран + * «работает» на демо-фикстурах и падает на живом API. + * + * Сверено с apps/api/prisma/schema.prisma и контроллерами: + * - даты приходят из колонок @db.Date и сериализуются как ISO-строка + * с UTC-полуночью («2026-08-07T00:00:00.000Z»); + * - Decimal(10,2) сериализуется СТРОКОЙ, не числом — сравнивать как строку; + * - source не enum, а свободная строка: manual | bot | marketplace. + */ + +export type BookingStatus = + | "HOLD" + | "CONFIRMED" + | "CHECKED_IN" + | "CHECKED_OUT" + | "CANCELLED" + +/** Локальное состояние отправки. На сервер не уходит и оттуда не приходит. */ +export type SyncState = "ok" | "pending" | "conflict" | "rejected" + +/** Разрешённые переходы. Соответствует apps/api/src/bookings/domain.ts. */ +export const NEXT: Record = { + HOLD: "CONFIRMED", + CONFIRMED: "CHECKED_IN", + CHECKED_IN: "CHECKED_OUT", + CHECKED_OUT: null, + CANCELLED: null, +} + +/** Статусы, занимающие номер. Совпадает с BLOCKING_STATUSES на бэкенде. */ +export const BLOCKING: readonly BookingStatus[] = [ + "HOLD", + "CONFIRMED", + "CHECKED_IN", +] + +export const CANCELLABLE: readonly BookingStatus[] = [ + "HOLD", + "CONFIRMED", + "CHECKED_IN", +] + +export type RoomType = { + id: string + propertyId: string + name: string + capacity: number + /** Decimal(10,2) → строка. */ + basePrice: string +} + +export type Room = { + id: string + propertyId: string + roomTypeId: string + /** То, что владелец пишет на ключе: «3», «Ю1». */ + label: string + roomType?: RoomType +} + +export type Guest = { + id: string + propertyId: string + name: string + phone: string | null + notes: string | null +} + +export type Property = { + id: string + name: string + slug: string | null + region: string | null + address: string | null + /** Все даты объекта читаются в этой зоне, а не в зоне устройства. */ + timezone: string + roomTypes?: RoomType[] + rooms?: Room[] +} + +export type Booking = { + id: string + propertyId: string + roomId: string + guestId: string + /** Полуинтервал [checkIn, checkOut): выезд и заезд в один день не конфликтуют. */ + checkIn: string + checkOut: string + status: BookingStatus + /** Decimal(10,2) → строка. */ + priceTotal: string + source: string + notes: string | null + /** Оптимистический лок. Сервер его отдаёт, но пока НЕ принимает обратно. */ + version: number + createdAt: string + updatedAt: string + guest?: Guest + room?: Room + /** Локальное поле, дописывается очередью. На сервер не уходит. */ + sync?: SyncState +} + +/** + * Строка очереди мутаций в том виде, в каком её показывает интерфейс. + * Живёт здесь, а не в useQueue, чтобы модуль фикстур мог её импортировать + * без кольцевой зависимости. + */ +export type QueueItem = { + id: number + state: "pending" | "conflict" | "rejected" + /** «Подтвердить · Петров Игорь» */ + title: string + /** «Номер 2» */ + subtitle: string + bookingId?: string + error?: string + /** Значение на телефоне и на сервере — только когда сервер их прислал. */ + mine?: string + theirs?: string + changedBy?: string + changedAt?: string +} + +/** Ячейка ответа GET /availability. Ответ РАЗРЕЖЁН: свободных дней в нём нет. */ +export type AvailabilityCell = { + date: string + bookingId: string + status: BookingStatus + guestName: string +} + +export type AvailabilityRow = { + roomId: string + label: string + roomTypeName: string + cells: AvailabilityCell[] +} + +export type AvailabilityResponse = { + from: string + to: string + rooms: AvailabilityRow[] +} diff --git a/apps/web/utils/dates.ts b/apps/web/utils/dates.ts new file mode 100644 index 0000000..20a80c8 --- /dev/null +++ b/apps/web/utils/dates.ts @@ -0,0 +1,80 @@ +/** + * Даты броней — КАЛЕНДАРНЫЕ, без времени: 'YYYY-MM-DD'. + * Интервал полуоткрытый: [checkIn, checkOut). Ночь принадлежит дню заезда, + * поэтому выезд 12-го и заезд 12-го в тот же номер — НЕ пересечение. + * + * Сервер отдаёт колонки @db.Date, и Prisma сериализует их как ISO-строку + * с UTC-полуночью («2026-08-07T00:00:00.000Z»). Прогонять их через + * локальный форматтер нельзя: западнее UTC сутки сдвинутся на день назад. + * Поэтому весь модуль работает в UTC и на строках, а не на Date. + */ + +const DAY_MS = 86_400_000 + +/** Любое серверное представление даты → 'YYYY-MM-DD'. */ +export function toDay(value: string): string { + return value.slice(0, 10) +} + +function utc(day: string): number { + const [y, m, d] = day.split("-").map(Number) + return Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1) +} + +export function addDays(day: string, n: number): string { + return new Date(utc(day) + n * DAY_MS).toISOString().slice(0, 10) +} + +/** Число ночей в полуинтервале. */ +export function nights(from: string, to: string): number { + return Math.round((utc(to) - utc(from)) / DAY_MS) +} + +export function eachDay(from: string, count: number): string[] { + return Array.from({ length: count }, (_, i) => addDays(from, i)) +} + +/** Ночь `day` занята бронью. Выезд в этот день ночь НЕ занимает. */ +export function coversNight( + b: { checkIn: string; checkOut: string }, + day: string, +): boolean { + const from = toDay(b.checkIn) + const to = toDay(b.checkOut) + return from <= day && day < to +} + +/** Пересечение полуинтервалов. Стык (выезд = заезд) даёт false. */ +export function overlaps( + a: { checkIn: string; checkOut: string }, + b: { checkIn: string; checkOut: string }, +): boolean { + return toDay(a.checkIn) < toDay(b.checkOut) && toDay(b.checkIn) < toDay(a.checkOut) +} + +/** «Сегодня» в таймзоне ОБЪЕКТА, а не устройства: владелец бывает в роуминге. */ +export function todayIn(timezone: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()) +} + +export function dayOfWeek(day: string): number { + return new Date(utc(day)).getUTCDay() +} + +export function isWeekend(day: string): boolean { + const d = dayOfWeek(day) + return d === 0 || d === 6 +} + +export function dayNumber(day: string): number { + return new Date(utc(day)).getUTCDate() +} + +export function monthIndex(day: string): number { + return new Date(utc(day)).getUTCMonth() +} diff --git a/apps/web/utils/money.ts b/apps/web/utils/money.ts new file mode 100644 index 0000000..aa4ed38 --- /dev/null +++ b/apps/web/utils/money.ts @@ -0,0 +1,15 @@ +/** + * Decimal(10,2) приходит из Prisma СТРОКОЙ, а не числом: «5700.00». + * Через Number его прогонять нельзя — на суммах выше 2^53 и на дробных + * тарифах теряется точность, а сверять «моё против серверного» в экране + * конфликта придётся посимвольно. + * + * Поэтому форматируем строку, не превращая её в число. + */ +export function formatMoney(value: string): string { + const [whole = "0", cents = ""] = value.split(".") + // Тонкая шпация U+2009 — разряды не слипаются и не разъезжаются + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, " ") + // Копейки печатаем только если они есть: «5 700», а не «5 700,00» + return /^0*$/.test(cents) ? grouped : `${grouped},${cents}` +} diff --git a/apps/web/utils/plural.ts b/apps/web/utils/plural.ts new file mode 100644 index 0000000..8260f5a --- /dev/null +++ b/apps/web/utils/plural.ts @@ -0,0 +1,17 @@ +/** + * Выбор формы множественного числа без библиотеки: три формы, шесть строк. + * Сами строки живут в словарях i18n — здесь только выбор ключа. + * + * Кыргызский счётных форм не требует, поэтому в ky-словаре все три ключа + * заполнены одинаково, и вызывающему коду не нужно знать язык. + */ +export type PluralForm = "one" | "few" | "many" + +export function pluralForm(n: number): PluralForm { + const mod10 = n % 10 + const mod100 = n % 100 + if (mod100 > 10 && mod100 < 20) return "many" + if (mod10 === 1) return "one" + if (mod10 > 1 && mod10 < 5) return "few" + return "many" +} diff --git a/apps/web/utils/status.ts b/apps/web/utils/status.ts new file mode 100644 index 0000000..b59ea21 --- /dev/null +++ b/apps/web/utils/status.ts @@ -0,0 +1,41 @@ +import type { BookingStatus } from "~/types" + +/** + * Статус НИКОГДА не передаётся одним цветом. + * + * Три статусных тона выровнены по светлоте в OKLCH (L 0,46–0,48), чтобы ни + * один не кричал громче остальных. Плата за это — в градациях серого они + * практически неразличимы: CONFIRMED и CHECKED_IN дают контраст ~1,01:1. + * Поэтому у статуса всегда есть второй канал, и в узких местах — глиф: + * он переживает и обесцвечивание, и дальтонизм, и полосу шириной 40px. + * Глиф печатается ПЕРЕД именем и в бюджет усечения имени не входит. + */ +const GLYPHS: Record = { + HOLD: "?", // решения ещё нет + CONFIRMED: "→", // заедет, ещё не в доме + CHECKED_IN: "●", // гость в доме + CHECKED_OUT: "✓", // закрыто + CANCELLED: "×", +} + +const COLOR_VARS: Record = { + HOLD: "var(--nc-status-hold)", + CONFIRMED: "var(--nc-status-confirmed)", + CHECKED_IN: "var(--nc-status-inhouse)", + CHECKED_OUT: "var(--nc-status-out)", + CANCELLED: "var(--nc-status-cancelled)", +} + +export function statusGlyph(status: BookingStatus): string { + return GLYPHS[status] +} + +/** Токен цвета статуса. Возвращается var(), а не значение: тема решает сама. */ +export function statusColorVar(status: BookingStatus): string { + return COLOR_VARS[status] +} + +/** Ключ словаря i18n для полного названия статуса. */ +export function statusKey(status: BookingStatus): string { + return `booking.status.${status}` +} diff --git a/docs/DEMO-SCRIPT.md b/docs/DEMO-SCRIPT.md new file mode 100644 index 0000000..d0963c1 --- /dev/null +++ b/docs/DEMO-SCRIPT.md @@ -0,0 +1,210 @@ +# Сценарий показа — Госагентство по развитию туризма + +Встреча с генеральным директором. Ядро — 15 минут, с вопросами 25–30. + +**Логика разговора:** его цифры → разрыв → откуда берутся данные → что получается → где начать → просьба. +Не «посмотрите, какое приложение», а «у вас нет величины, и вот откуда она возьмётся». + +--- + +## 0. Подготовка, за 15 минут до + +``` +cd apps/web +node .output/server/index.mjs +``` + +Открыть **две вкладки** и не переключать порядок: + +| Вкладка | Адрес | Режим окна | +|---|---|---| +| 1 | `http://localhost:3000/gov/occupancy` | обычное, широкое | +| 2 | `http://localhost:3000/` | режим устройства, **360 × 780** (`Ctrl+Shift+M`) | + +Проверить перед входом: + +- обе вкладки отвечают, таблицы на месте; +- в первой вкладке видна полоса **«СИНТЕТИЧЕСКИЕ ДАННЫЕ»** — она должна стоять в Части 3, а не под шапкой. Если она вверху — сборка старая, это критично; +- во второй вкладке на «Сегодня» стоят счётчики **3 / 2 / 1**; +- светлая тема. Тёмную не показывать: проектор её съест. + +Запасной вариант, если что-то не поднялось: снимки в `docs/web-proof/`. Прогонять весь разговор по картинкам можно, но хуже — исчезает эффект живого нажатия. + +--- + +## 1. Заход. Его цифры (1 минута) + +**Вкладка 1, Часть 1. Ничего не нажимать, просто дать посмотреть.** + +> «Прежде чем показывать своё, я собрал то, что публикуется. Валовая добавленная стоимость туризма — 58 миллиардов за 2024-й, доля в ВВП 3,8 %, услуги гостиниц и ресторанов — 73 миллиарда за 2025-й. Под каждой цифрой стоит год и источник.» + +Показать пальцем на подписи источников. + +> «Там, где данные из публикаций, а не из открытых рядов Нацстаткома, это помечено отдельно. Если у вас по ним другие цифры — ваши точнее, подставим.» + +**Зачем этот шаг:** он видит, что вы не пришли с презентацией про светлое будущее. Вы пришли, зная его отрасль в цифрах. + +--- + +## 2. Разрыв (2 минуты) — главный момент встречи + +**Прокрутить к Части 2. Не торопиться.** + +> «А это то, чего нет ни в одном отчёте страны. Номерной фонд — сколько всего номеров. Предложение в номеро-сутках. Продано номеро-суток. И, как следствие, загрузка.» + +Пауза. + +> «Загрузку сегодня в Кыргызстане не может посчитать никто. Не потому что не хотят — потому что нет числа в знаменателе. Считается, сколько людей приехало и сколько денег потратили. Сколько номеров при этом стояло пустыми — не считает никто.» + +**Проверяемое утверждение — предложить проверить при нём:** + +> «Это легко проверить прямо сейчас: stat.gov.kg, раздел открытых данных. Ряда «номерной фонд» там нет ни одного. Я проверял восьмого августа.» + +Если он захочет — открыть. Это сильнее любого слайда. + +> «Государство считает поток и деньги. Предложение снизу не соберёшь никак, кроме как с каждого объекта по отдельности.» + +--- + +## 3. Откуда берутся данные (3 минуты) — переход на телефон + +**Вкладка 2. Сказать зачем переключаетесь.** + +> «Теперь короткое отступление — откуда эта величина вообще может взяться. Это не отчётная форма. Это рабочий инструмент владельца гостевого дома, и данные появляются как побочный продукт того, что он им пользуется.» + +### 3.1. «Сегодня» — 40 секунд + +> «Ак-Марал, Боконбаево, десять номеров. Владелец открывает утром: три заезда, два выезда, один ждёт решения. Без прокрутки, без меню.» + +**Нажать плитку «Ждут»** → список сузился. **Нажать ещё раз** → вернулся. + +**Нажать нижнюю кнопку «Подтвердить · Петров Игорь»** → бронь подтвердилась. + +> «Одно действие, одна кнопка, и она единственная залитая на экране. Всё остальное — контур. Экран всегда говорит, что делать прямо сейчас.» + +### 3.2. Стык — 60 секунд, самое убедительное для отраслевого человека + +**Перейти на «Занятость», режим «День». Показать строку номера 3.** + +> «Вот здесь: Ким Мария выезжает сегодня, Chen Wei сегодня же заезжает. В тетради это самое частое место ошибки — владелец видит, что номер занят, и не берёт вторую бронь. Или берёт и заселяет двоих.» + +> «Система считает интервал полуоткрытым: ночь принадлежит дню заезда. Поэтому выезд и заезд в один день — не конфликт, и написано это словами, а не значком.» + +**Переключить на «Сетка», прокрутить вбок к 7–8 августа.** + +> «Здесь то же самое геометрией: полосы соприкасаются, но не перекрываются.» + +### 3.3. Очередь — 40 секунд + +**Перейти на «Очередь».** + +> «Нарын, юг, перевалы — связи нет. Изменения не теряются, они ждут в очереди, и владелец видит, сколько не ушло. А вот отказ сервера: номер успели занять с другого устройства. Система физически не даёт поселить двоих в один номер — это проверено тестом на конкурентных запросах.» + +**Показать кнопку «КЫР» в шапке «Сегодня», переключить, вернуть.** + +> «Интерфейс на кыргызском. Не перевод для галочки — вёрстка рассчитана на то, что кыргызский длиннее русского.» + +--- + +## 4. Что получается (3 минуты) — обратно на его экран + +**Вкладка 1, Часть 3.** + +> «Теперь те же четыре показателя, которых не было. Измеренные, а не оценённые.» + +**Прокрутить к блоку «Сверки».** + +> «И главное. Три разреза одной совокупности: по регионам, по категориям классификации, по месяцам. Они обязаны сойтись, и проверка стоит прямо на экране. Загрузка не хранится готовым числом — она пересчитывается из двух соседних столбцов, вот формула.» + +> «Если выгрузка когда-нибудь разъедется, галочка станет крестом здесь, а не через квартал в чужом отчёте.» + +**Показать блок «Охват».** + +> «И оговорка, которая уходит наверх вместе с цифрой: показатели считаются только по подключённым объектам и потому занижены. Долю охвата надо называть вместе с числом.» + +--- + +## 5. Где начать (3 минуты) + +**Нажать ссылку «Разрез по районам: Иссык-Кульская область →».** + +> «Пилот не может быть «по стране». Он должен быть в одном районе, и район выбирается расчётом.» + +**Показать столбец «Разрыв».** + +> «Тонский район, Боконбаево. Девяносто четыре объекта из ста шестидесяти восьми ещё не в системе. Охват сорок четыре процента — самый низкий по области при значимом размере фонда.» + +> «Выбран по числу неподключённых, а не по низшему проценту: сорок процентов охвата при фонде в двадцать домов — это восемь домов работы, а не пилот.» + +**Показать строку «Цель сезона».** + +> «Сорок семь объектов за сезон поднимут охват района до семидесяти двух процентов. И тогда у вас впервые появится измеренная загрузка целого района.» + +--- + +## 6. Просьба (2 минуты) — не деньги + +> «Я не прошу финансирования и не прошу приказа. Приказ здесь только навредит — об этом через минуту.» + +**Три вещи:** + +1. **Легитимность.** Пилот под эгидой агентства или соглашение о сотрудничестве. Пятидесятилетний владелец в Боконбаево доверяет официальной бумаге на порядок больше, чем приложению из интернета. Это самый дешёвый ускоритель, который вообще существует. +2. **Канал.** Один выезд с человеком из областного представительства равен месяцу холодных обходов. +3. **Один район на один сезон.** Проверяемо. Через три месяца — цифра, которой в стране никогда не было. + +--- + +## 7. Красную линию проговорить самому, до того как он спросит + +**Не ждать вопроса. Сказать первым — это половина доверия к вам.** + +> «Есть риск, который надо назвать сразу. Если это сделать обязательным или если пойдёт слух, что данные уходят в налоговую, владелец начнёт вести две тетради, и в вашей будет ложь. Данные хорошие ровно потому, что он вносит их добровольно и для себя.» + +> «Наверх уходит только обезличенный агрегат: объекты, номера, предложение, продано, загрузка. Никогда — выручка конкретного дома, гости и документы.» + +--- + +## 8. Вопросы, которые он задаст + +**«Чьи это данные?»** +Операционные — владельца, он их вносит для себя. Агрегат по региону — агентства. В соглашении это разделяется явно, вместе с правом выгрузить агрегат в любой момент. + +**«Где хранятся?»** +Сейчас — обычный сервер. При соглашении разворачивается в Кыргызстане, это не проблема архитектуры. + +**«А если вы закроетесь?»** +Поэтому агрегат должен выгружаться агентству регулярно, а не по запросу. Это пункт соглашения, а не обещание. + +**«Сколько это стоит владельцу?»** +Учёт и защита от двойной брони — бесплатно всегда. Это принцип, а не акция: платный учёт останавливает захват, а без захвата нет данных. Платное — автоматизация: бот, уведомления. + +**«Чем вы лучше готовых систем для отелей?»** +Они рассчитаны на отель с постоянным интернетом и администратором на стойке. Гостевой дом на перевале — другой сценарий: связи нет, администратора нет, телефон дешёвый. И ни одна из них не строит национальный реестр предложения, потому что не ставит такой задачи. + +**«Почему владелец вообще будет этим пользоваться?»** +Он конкурирует не с нами, а с тетрадью. Продукт обязан быть быстрее тетради, иначе им не пользуются. Поэтому «что делать сейчас» — на первом экране без прокрутки, а не в меню. + +**«Данные синтетические — а реальные когда?»** +Через сезон пилота в одном районе. Синтетика подписана честно и стоит только в третьей части; первая часть — ваши опубликованные цифры. + +**«У нас другие цифры по гостиницам»** +Ваши точнее. Те, что помечены как публикации СМИ, я не сверял с первоисточником — дайте ваши, подставим за час. + +--- + +## 9. Чего не делать + +- Не показывать шесть экранов владельца подряд. Три касания на телефоне, не больше. +- Не обещать покрытие страны. Обещать один район за один сезон. +- Не просить денег. +- Не говорить «стартап», «MVP», «дорожная карта». Говорить «пилот», «район», «сезон», «охват». +- Не открывать тёмную тему на проекторе. +- Не углубляться в технику, если не спросят. Если спросят про анти-овербукинг — одна фраза: «сериализуемая транзакция с блокировкой строки, есть тест на конкурентные запросы, прогнан на живой базе». + +--- + +## 10. Чем закончить + +Одна фраза, которую он должен унести: + +> «Сегодня страна знает, сколько туристов приехало. Она не знает, сколько номеров при этом стояло пустыми. Это можно измерить за один сезон в одном районе, и для этого не нужны ни бюджет, ни приказ — нужна ваша подпись под пилотом.» diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..aceae61 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,63 @@ +# NomadCore — Что сделано и что осталось + +> Дата актуализации: 30.07.2026 · Версия кода: 0.1.0 +> Связанные документы: [DESIGN.md](./DESIGN.md) (архитектура и ADR) · [UI-DESIGN.md](./UI-DESIGN.md) · [API.md](./API.md) · [DEPLOYMENT.md](./DEPLOYMENT.md) + +Этот файл — единая точка правды по состоянию работ. `DESIGN.md` отвечает на вопрос +«как устроено и почему», этот файл — «что уже стоит на диске, а что нет». + +--- + +## 1. Готово + +### Фаза 0.1 — «Тетрадка» (PMS-lite), Sprint 0–3 + +| Блок | Состояние | +|---|---| +| Монорепо pnpm (`apps/api`, `apps/web`), Docker Compose (PostgreSQL 16 + Redis 7) | ✅ | +| Prisma-схема + 2 миграции в репозитории (`init_sprint4`, `property_slug`) | ✅ | +| Seed демо-объекта «Ак-Кеме» | ✅ | +| Auth: OTP по телефону (stub) + JWT | ✅ | +| CRUD: объект, типы номеров, номера, гости | ✅ | +| Брони: создание, список по диапазону, смена статуса | ✅ | +| Анти-овербукинг: Serializable + `SELECT … FOR UPDATE` + ретраи (ADR-1) | ✅ код | +| Доменное ядро без Nest/Prisma (`bookings/domain.ts`) + юнит-тесты | ✅ | +| Сетка доступности «номер × дни» для календаря | ✅ | +| Nuxt 3 PWA: Сегодня / Календарь / Номера / Гости / Настройки | ✅ | +| Offline-first: Dexie-кэш GET + очередь мутаций | ✅ | +| i18n (`ru`, `ky`, плюс незадокументированный `en`) | ✅ | +| CI: lint → typecheck → migrate deploy → unit-тесты | ✅ | + +### Фаза 0.2 — Бот и подписка, Sprint 4 + +| Блок | Состояние | +|---|---| +| Telegram webhook + секрет, `TELEGRAM_MODE=stub` | ✅ | +| Диалоговая машина бота как чистая функция + юнит-тесты | ✅ | +| Transactional outbox (`outbox_events`) + BullMQ-релей с `jobId = event.id` | ✅ | +| Воркер уведомлений поверх outbox | ✅ | +| Биллинг: `GET /billing/me`, ручная активация Pro (`POST /billing/activate`) | ✅ | +| Публичная витрина `public/:slug` (карточка, доступность, HOLD-бронь) | ✅ код, ❌ документация до 30.07.2026 | + +--- + +## 2. Не проверено на живой среде (первоочередное) + +Код писался без доступа к npm registry, поэтому ни одна команда сборки/тестов +не выполнялась против реальных PostgreSQL и Redis. До пилота это блокирующий пункт. + +- [ ] `pnpm install` → `prisma:deploy` → `pnpm test` — первый реальный прогон. +- [ ] `pnpm --filter @nomadcore/api test:races` на живой БД — проверка инварианта ADR-1 + (N конкурентных броней одного номера → проходит ровно одна). +- [ ] Прогон outbox-релея и воркера уведомлений с живым Redis. +- [ ] Сквозной путь бота на реальном токене (`TELEGRAM_MODE=live` + webhook по HTTPS). +- [ ] Замер веса JS и времени первой отрисовки PWA на дешёвом Android. + +### Пробелы CI + +- [ ] В `.github/workflows/ci.yml` нет сервиса Redis — outbox и уведомления не покрыты. +- [ ] `pnpm test` запускает только два юнит-файла (`domain.test.ts`, `bot-flow.test.ts`); + `test:races` в CI **не вызывается**, хотя PostgreSQL в workflow уже поднят. +- [ ] Нет прогона сборки `apps/web` (`nuxt build`) в CI. + +PLACEHOLDER_ROADMAP diff --git a/docs/WEB-API-GAPS.md b/docs/WEB-API-GAPS.md new file mode 100644 index 0000000..4732e13 --- /dev/null +++ b/docs/WEB-API-GAPS.md @@ -0,0 +1,148 @@ +# Чего фронту не хватает в API + +Составлено при пересборке `apps/web` на дизайн-системе (ветка `feat/web-redesign`). +Сверено построчно с 27 обработчиками в `apps/api/src/**/*.controller.ts` +(глобальный префикс `/api`) и с `apps/api/prisma/schema.prisma`. + +`apps/api` в этой работе не менялся. Документ — постановка задачи на бэкенд, +а не список обходных путей. Ни одна из перечисленных дыр на клиенте не +замокана: там, где поля нет, экран показывает блок неактивным и пишет причину. + +--- + +## 1. Отсутствующие поля + +Прототип «NomadCore Владелец 360» печатает на карточке брони четыре +значения, которых в схеме нет ни у `Booking`, ни у `Guest`: + +| Что показывает прототип | Состояние | +|---|---| +| Код брони («B-1044») | поля нет; `id` — это `cuid()`, владельцу его не назовёшь | +| Гражданство гостя | поля нет | +| Число гостей (`pax`) | поля нет; `RoomType.capacity` — это вместимость номера, а не факт заселения | +| «Паспорт предъявлен» / «Не предъявлены» | поля нет | + +Последнее — не косметика. На нём висит целый сценарий: при заселении без +документов прототип показывает шторку «Заселить без документов?» с +пояснением «паспортные данные можно внести позже, до конца суток». Без поля +сценарий не существует. + +Предлагаемый минимум: `Booking.code` (или человекочитаемый публичный +идентификатор), `Booking.pax Int`, `Guest.citizenship String?`, +`Guest.documentNo String?`. Последнее уже описано в +`nuxt/API-CONFLICTS.md` как поле с разрешением конфликтов по last-write. + +--- + +## 2. Отсутствующие эндпоинты + +### `GET /api/bookings/:id` — нет + +Одиночную бронь загрузить нечем. «Занятость» отдаёт только `bookingId`, +и карточка вынуждена искать бронь в уже загруженном диапазоне +(`useBookings().byId`). Бронь за пределами горизонта в 21 день не откроется +вовсе — экран честно пишет «Бронь не найдена среди загруженных». + +### `PATCH /api/bookings/:id` — нет + +После создания неизменяемы: даты, номер, гость, заметки, цена. Сервер умеет +ровно один переход — `PATCH /api/bookings/:id/status` с телом `{ status }`. + +Из-за этого не реализуемы три действия карточки: **«Продлить на 1 ночь»**, +**«Ранний выезд сегодня»** и **«Отметить документы»**. В интерфейсе они +оставлены на своих местах неактивными, с подписью причины. + +### `DELETE /api/bookings/:id` — нет + +Отмена только через переход статуса в `CANCELLED`. Для интерфейса это +приемлемо, но отменённые брони остаются обычными строками в `bookings` +и в индексе `[roomId, checkIn, checkOut]` — любой запрос занятости обязан +отфильтровывать их вручную. + +--- + +## 3. Поведение, которое ломает экраны + +### `GET /api/bookings` молча теряет сегодняшние выезды + +Условие в `bookings.service.ts`: `checkIn < to && checkOut > from`. +Запрос `from=сегодня&to=завтра` даёт `checkOut > сегодня` и **исключает все +брони с `checkOut = сегодня`** — то есть ровно те выезды, ради которых +существует экран «Сегодня». + +Обход на клиенте: окно всегда расширяется на сутки назад, отбор идёт +локально (`useBookings.ts`). Работает, но это обход, а не решение: +владелец с большим объектом получает лишние данные на каждый запрос. + +Сюда же: `from` и `to` работают только парой. Передать один — значит молча +получить всю историю. Пагинации нет нигде. + +### Конфликт версий приходит как `500`, а не `409` + +`ChangeStatusDto` содержит одно поле `status`. Ни `expectedVersion`, +ни заголовка `If-Match`. Сервер сам читает бронь и пишет +`update where { id, version }`; при промахе Prisma бросает `P2025`, +который никто не ловит. + +Следствие: клиент **не может отличить конфликт версий от поломки сервера**. +`Booking.version` в схеме есть и в ответах приходит, но принять его обратно +API не умеет — оптимистичный лок односторонний. + +### `409` сегодня означает бизнес-отказ, а не конфликт версий + +Это инверсия того, что предполагает `nuxt/API-CONFLICTS.md`: + +| Код | Что предполагал дизайн | Что отдаёт API сейчас | +|---|---|---| +| `409` | конфликт версий, решает владелец | `{code:"OVERBOOKING"}`, `{code:"INVALID_TRANSITION"}` — решать нечего | +| `422` / `423` | бизнес-отказ | не возвращается никогда | +| конфликт версий | `409` с телом | `500` | + +Раскладка кодов в очереди мутаций сделана по РЕАЛЬНОМУ контракту: +`409` с полем `code` → `rejected`, `409` с телом `booking` → `conflict` +(задел на будущее, сегодня не срабатывает), `422`/`423` → `rejected`. + +### Нет идемпотентности + +Ни `POST /api/bookings`, ни `PATCH .../status` не принимают ключ +идемпотентности. Повторная отправка мутации из офлайн-очереди после +оборвавшейся сети создаёт дубль брони — либо ловит `OVERBOOKING` +на собственную же первую попытку. + +### `GET /api/availability` разрежён + +Свободных дней в ответе нет вовсе — только занятые ячейки. Ни `checkIn`, +ни `checkOut` в ячейке нет, поэтому многодневную полосу пришлось бы +собирать группировкой по `bookingId`. Валидации `from`/`to` нет +(ни DTO, ни pipe), ограничения диапазона тоже (92 дня стоят только +на публичном `/api/public/:slug/availability`). + +Поэтому «Занятость» считается из списка броней, а не из этого эндпоинта. +Побочная выгода: список броней знает про локальные неотправленные +изменения, а `/availability` — нет. + +### Нет канала «что изменилось» + +`GET /api/bookings` фильтрует только по пересечению дат: ни `updatedSince`, +ни курсора, ни `Last-Modified`. Outbox (`OutboxEvent`) внутренний, +контроллером не выставлен. Единственная стратегия сверки — полная +перезагрузка, причём без пагинации. + +--- + +## 4. Мелочи, замеченные при сверке + +- Разнобой в кодах ошибок: rooms/room-types/guests/availability отдают + **403 `NO_PROPERTY`**, а bookings и billing — **404 `PROPERTY_NOT_FOUND`**. +- `PATCH /api/bookings/:id/status` при чужой брони отдаёт голый + `403 Forbidden` без машиночитаемого кода — единственный такой ответ в API. +- Ответ `PATCH .../status` не включает `room.roomType`, хотя `GET /bookings` + включает: после перехода карточка теряет название типа номера. +- `POST /api/bookings` с `guestId` из чужого объекта попадает в + `findFirstOrThrow` → **500**, а не 404. +- `POST /api/public/:slug/bookings` пишет `source: "marketplace"` — третье + значение, которого нет в `@IsIn(["manual","bot"])` у `CreateBookingDto`. +- `Booking.priceTotal` и `RoomType.basePrice` — `Decimal(10,2)`, приходят + СТРОКОЙ. Сравнивать их как числа нельзя (см. `utils/money.ts`). +- У `Room`, `Guest`, `RoomType` колонки `version` нет вовсе: их правки — + безусловный last-write. diff --git a/docs/web-proof/card-hold-light-ru.png b/docs/web-proof/card-hold-light-ru.png new file mode 100644 index 0000000..594918e Binary files /dev/null and b/docs/web-proof/card-hold-light-ru.png differ diff --git a/docs/web-proof/card-inhouse-dark-ky.png b/docs/web-proof/card-inhouse-dark-ky.png new file mode 100644 index 0000000..54ce693 Binary files /dev/null and b/docs/web-proof/card-inhouse-dark-ky.png differ diff --git a/docs/web-proof/card-inhouse-light-ru.png b/docs/web-proof/card-inhouse-light-ru.png new file mode 100644 index 0000000..4fbf353 Binary files /dev/null and b/docs/web-proof/card-inhouse-light-ru.png differ diff --git a/docs/web-proof/create-dark-ky.png b/docs/web-proof/create-dark-ky.png new file mode 100644 index 0000000..0500ea0 Binary files /dev/null and b/docs/web-proof/create-dark-ky.png differ diff --git a/docs/web-proof/create-light-ky.png b/docs/web-proof/create-light-ky.png new file mode 100644 index 0000000..1d3b838 Binary files /dev/null and b/docs/web-proof/create-light-ky.png differ diff --git a/docs/web-proof/create-light-ru.png b/docs/web-proof/create-light-ru.png new file mode 100644 index 0000000..d6f57c7 Binary files /dev/null and b/docs/web-proof/create-light-ru.png differ diff --git a/docs/web-proof/gaps-on-light-ru.png b/docs/web-proof/gaps-on-light-ru.png new file mode 100644 index 0000000..b48797d Binary files /dev/null and b/docs/web-proof/gaps-on-light-ru.png differ diff --git a/docs/web-proof/gov-dark.png b/docs/web-proof/gov-dark.png new file mode 100644 index 0000000..b553507 Binary files /dev/null and b/docs/web-proof/gov-dark.png differ diff --git a/docs/web-proof/gov-district-dark.png b/docs/web-proof/gov-district-dark.png new file mode 100644 index 0000000..962d2fb Binary files /dev/null and b/docs/web-proof/gov-district-dark.png differ diff --git a/docs/web-proof/gov-district-light.png b/docs/web-proof/gov-district-light.png new file mode 100644 index 0000000..6ced0e1 Binary files /dev/null and b/docs/web-proof/gov-district-light.png differ diff --git a/docs/web-proof/gov-light.png b/docs/web-proof/gov-light.png new file mode 100644 index 0000000..08af22e Binary files /dev/null and b/docs/web-proof/gov-light.png differ diff --git a/docs/web-proof/live/live-02-login-filled.png b/docs/web-proof/live/live-02-login-filled.png new file mode 100644 index 0000000..235f4ea Binary files /dev/null and b/docs/web-proof/live/live-02-login-filled.png differ diff --git a/docs/web-proof/live/live-04-today.png b/docs/web-proof/live/live-04-today.png new file mode 100644 index 0000000..2c43395 Binary files /dev/null and b/docs/web-proof/live/live-04-today.png differ diff --git a/docs/web-proof/live/live-08-occupancy-grid.png b/docs/web-proof/live/live-08-occupancy-grid.png new file mode 100644 index 0000000..1f57816 Binary files /dev/null and b/docs/web-proof/live/live-08-occupancy-grid.png differ diff --git a/docs/web-proof/live/live-11-card.png b/docs/web-proof/live/live-11-card.png new file mode 100644 index 0000000..396a17a Binary files /dev/null and b/docs/web-proof/live/live-11-card.png differ diff --git a/docs/web-proof/login-dark-ru.png b/docs/web-proof/login-dark-ru.png new file mode 100644 index 0000000..c7132e9 Binary files /dev/null and b/docs/web-proof/login-dark-ru.png differ diff --git a/docs/web-proof/login-light-ru.png b/docs/web-proof/login-light-ru.png new file mode 100644 index 0000000..305a359 Binary files /dev/null and b/docs/web-proof/login-light-ru.png differ diff --git a/docs/web-proof/occ-day-dark-ky.png b/docs/web-proof/occ-day-dark-ky.png new file mode 100644 index 0000000..9fa29b5 Binary files /dev/null and b/docs/web-proof/occ-day-dark-ky.png differ diff --git a/docs/web-proof/occ-day-dark-ru.png b/docs/web-proof/occ-day-dark-ru.png new file mode 100644 index 0000000..2b2255e Binary files /dev/null and b/docs/web-proof/occ-day-dark-ru.png differ diff --git a/docs/web-proof/occ-day-light-ky.png b/docs/web-proof/occ-day-light-ky.png new file mode 100644 index 0000000..10713dc Binary files /dev/null and b/docs/web-proof/occ-day-light-ky.png differ diff --git a/docs/web-proof/occ-day-light-ru.png b/docs/web-proof/occ-day-light-ru.png new file mode 100644 index 0000000..59982b8 Binary files /dev/null and b/docs/web-proof/occ-day-light-ru.png differ diff --git a/docs/web-proof/occ-grid-dark-ky.png b/docs/web-proof/occ-grid-dark-ky.png new file mode 100644 index 0000000..2d15cc8 Binary files /dev/null and b/docs/web-proof/occ-grid-dark-ky.png differ diff --git a/docs/web-proof/occ-grid-light-ru.png b/docs/web-proof/occ-grid-light-ru.png new file mode 100644 index 0000000..5357d0f Binary files /dev/null and b/docs/web-proof/occ-grid-light-ru.png differ diff --git a/docs/web-proof/occ-nights-dark-ru.png b/docs/web-proof/occ-nights-dark-ru.png new file mode 100644 index 0000000..8977d42 Binary files /dev/null and b/docs/web-proof/occ-nights-dark-ru.png differ diff --git a/docs/web-proof/occ-nights-light-ky.png b/docs/web-proof/occ-nights-light-ky.png new file mode 100644 index 0000000..37beef6 Binary files /dev/null and b/docs/web-proof/occ-nights-light-ky.png differ diff --git a/docs/web-proof/queue-dark-ky.png b/docs/web-proof/queue-dark-ky.png new file mode 100644 index 0000000..db550de Binary files /dev/null and b/docs/web-proof/queue-dark-ky.png differ diff --git a/docs/web-proof/queue-light-ru.png b/docs/web-proof/queue-light-ru.png new file mode 100644 index 0000000..e3d47f1 Binary files /dev/null and b/docs/web-proof/queue-light-ru.png differ diff --git a/docs/web-proof/today-dark-ky.png b/docs/web-proof/today-dark-ky.png new file mode 100644 index 0000000..7c45da5 Binary files /dev/null and b/docs/web-proof/today-dark-ky.png differ diff --git a/docs/web-proof/today-dark-ru.png b/docs/web-proof/today-dark-ru.png new file mode 100644 index 0000000..d35f4a9 Binary files /dev/null and b/docs/web-proof/today-dark-ru.png differ diff --git a/docs/web-proof/today-light-ky.png b/docs/web-proof/today-light-ky.png new file mode 100644 index 0000000..0311dac Binary files /dev/null and b/docs/web-proof/today-light-ky.png differ diff --git a/docs/web-proof/today-light-ru.png b/docs/web-proof/today-light-ru.png new file mode 100644 index 0000000..7a9c6f1 Binary files /dev/null and b/docs/web-proof/today-light-ru.png differ diff --git a/nuxt/API-CONFLICTS.md b/nuxt/API-CONFLICTS.md new file mode 100644 index 0000000..6901a78 --- /dev/null +++ b/nuxt/API-CONFLICTS.md @@ -0,0 +1,146 @@ +# Контракт разрешения конфликтов — требование к API + +Статус: **решение принято — выбор за владельцем. На бэкенде не реализовано, +требует отдельной задачи.** +Заведено на Этапе 2 по итогам проектирования офлайн-слоя. + +Короткая формулировка решения: **last-write отменяется для `checkIn`, +`checkOut`, `status` и `roomId`; для остальных полей сохраняется.** По +четырём перечисленным полям сервер обязан вернуть обе версии и ждать выбора +человека. + +## В чём расхождение + +Исходное описание системы: «конфликты решаются last-write плюс колонка +`version` как оптимистичный лок». + +Интерфейс, спроектированный на Этапе 2, этого контракта **не выполняет**. +Экран «Очередь» при конфликте показывает владельцу оба значения — своё и +серверное, — кто и когда изменил запись, и две равнозначные кнопки: +«Оставить моё» / «Принять серверное». Автоматической записи последнего +пишущего для дат и статуса нет. + +Это осознанное изменение, а не недосмотр вёрстки, и его нельзя закрыть +на клиенте: клиент не может показать серверное значение, если сервер его +не прислал. + +## Почему дизайн отказался от last-write + +Владелец офлайн продлевает гостя до 11 августа. Одновременно администратор +с другого устройства оформляет ранний выезд 8-го. Сеть возвращается. + +При last-write выигрывает тот, чей запрос пришёл вторым. Если это ранний +выезд — номер освобождается и уходит следующей брони, **а гость физически +живёт в комнате**. Владелец узнаёт об этом, когда на пороге стоят двое. + +Цена ошибки здесь не «перезаписанное поле», а выселенный человек и +испорченная репутация дома в посёлке, где все друг друга знают. Автоматика +не имеет данных, чтобы выбрать правильно: оба изменения корректны, знает +только человек. + +## Разделение полей + +| Поле | Разрешение | Обоснование | +|---|---|---| +| `checkIn`, `checkOut` | **владелец** | двойное заселение, выселение живого гостя | +| `status` | **владелец** | отмена подтверждённой брони, заселение отменённой | +| `roomId` | **владелец** | овербукинг | +| `guest.documentNo` | last-write | монотонно: документы появляются, не исчезают | +| `guest.fullName`, заметки | last-write | цена ошибки — опечатка | +| `pax`, `rate` | last-write | правится на месте, конфликтует редко | + +Формально: last-write допустим там, где обе версии совместимы. Даты, статус +и номер задают физическое размещение людей — там совместимости нет. + +## Что должен делать API + +**1. Отвечать 409 с телом, а не пустым статусом.** + +`PATCH /api/bookings/:id` с заголовком `If-Match: `. При устаревшей +версии — `409 Conflict`: + +```json +{ + "booking": { "...": "полное текущее состояние на сервере" }, + "changedBy": "Нурбек · устройство «Ресепшн»", + "changedAt": "2026-08-07T12:48:00+06:00", + "changedFields": ["checkOut"] +} +``` + +Без `booking` в теле клиенту нечего показать в колонке «На сервере», а без +`changedBy` / `changedAt` владелец не понимает, чьё это изменение и не +устарело ли оно само. + +**2. Принимать повторную отправку на новой версии.** + +Выбор «Оставить моё» отправляет ту же мутацию с `If-Match`, равным версии из +409-ответа. Сервер обязан её принять, если за это время не появилась третья +версия. Отдельного «force»-флага не нужно — и не нужно его добавлять: он +превратится в тот же last-write через чёрный ход. + +**3. Различать 409 и 422.** + +`409` — конфликт версий, решает владелец. `422`/`423` — бизнес-отказ +(номер занят другой бронью), решать нечего: интерфейс предлагает переселить +или отклонить. Сейчас клиент различает их по коду, и объединять их нельзя. + +**4. Не терять `changedBy` в аудите.** + +Поле берётся из журнала изменений брони. Если журнала нет — он тоже часть +этой задачи: без имени и времени экран конфликта показывает два безымянных +значения, и владелец выбирает наугад. + +## Что происходит с очередью, пока владелец не выбрал + +Мутация остаётся в очереди со статусом `conflict`. Правила: + +1. **Бронь блокируется, остальные — нет.** Очередь упорядочена по времени, и + мутации по одной броне отправляются строго последовательно. Конфликтная + мутация блокирует только свою бронь; изменения по другим броням продолжают + уходить. Иначе один спорный гость останавливает весь дом. + +2. **Локальное состояние не откатывается.** Экран показывает значение, + которое ввёл владелец, с пометкой «Не отправлено». Молчаливый откат к + серверному был бы тем же last-write, только незаметным. + +3. **Бронь помечена во всех списках.** Красная плашка «Конфликт версии» стоит + в строке списка и в карточке, а не только на экране очереди. + +4. **Новые изменения той же брони разрешены** и встают в очередь за + конфликтной. После разрешения они отправляются в исходном порядке — + на версии, полученной в результате выбора. + +## Если владелец не выберет никогда + +Это штатный исход, а не сбой: конфликт мог возникнуть по брони, которая уже +не важна. Правила: + +- **Ничего не отправляется само.** Ни по таймауту, ни при переустановке + приложения, ни при выходе из аккаунта. Отложенная автоотправка — тот же + last-write, только через месяц и без свидетелей. +- **Конфликт не истекает и не удаляется.** Счётчик на вкладке «Очередь» + остаётся красным, пока решение не принято. +- **Сервер живёт своей жизнью.** Серверная версия остаётся действующей: для + всех остальных устройств бронь уже разрешена. Нерешённый конфликт — это + только неотправленное изменение на одном телефоне. +- **Снимок сервера устаревает.** Если запись изменили ещё раз, при повторной + отправке «Оставить моё» снова вернётся `409` — уже с новым снимком, и + владелец выберет заново. Число повторов не ограничивается. +- **Очистить очередь можно только явно** — действием «Отклонить» в карточке + конфликта. Оно удаляет мутацию и снимает пометку с брони. + +## Что уже готово на клиенте + +`nuxt/composables/useSync.ts` — состояния `conflict` и `rejected` в очереди, +`keepMine()` и `takeServer()`, снимок сервера в `QueuedMutation.server`. +`nuxt/components/ConflictCard.vue` — экран выбора. + +Пока API не отдаёт тело 409, клиент показывает конфликт без колонки +«На сервере»: выбор превращается в «повторить или отменить». Это деградация, +а не рабочий сценарий. + +## Оценка + +Со стороны бэкенда: тело 409, журнал изменений с автором, разделение +409/422. Оценивается отдельно от интерфейса и в бюджет Этапа 2 не входит. diff --git a/nuxt/README.md b/nuxt/README.md new file mode 100644 index 0000000..12a971e --- /dev/null +++ b/nuxt/README.md @@ -0,0 +1,99 @@ +# NomadCore — Nuxt 3 / Vue 3 / Tailwind + +Код для прототипа NomadCore.dc.html. Библиотека компонентов не подключается: все примитивы свои. + +## Бюджет веса (первая загрузка владельца, gzip) + +Считается ровно то, что скачивает владелец при первом открытии на дешёвом +Android. Кабинет госагентства (`/gov/*`) в этот бюджет НЕ входит: это +отдельный маршрут, отдельный чанк и другое устройство. + +| Что | Оценка | +|---|---| +| Nuxt 3 runtime + Vue 3 | ~ 42 КБ | +| vue-router (4 маршрута владельца) | ~ 11 КБ | +| dexie | ~ 24 КБ | +| наш код: 9 примитивов, 4 страницы, composables, словарь ru+kg | ~ 16 КБ | +| **Итого на первую загрузку** | **≈ 93 КБ** | +| `/gov/occupancy` — отдельный чанк, грузится только в кабинете | + ~ 6 КБ | + +Чем бюджет держится: библиотеки компонентов нет, иконочного шрифта нет +(12 путей в `NcIcon.vue`), веб-шрифтов нет (системный стек), библиотеки дат +нет (`utils/dates.ts` — 40 строк на полуинтервалах), библиотеки графиков нет +(три рисунка в кабинете — инлайновый SVG), i18n-библиотеки нет +(`useI18n.ts` — один ref и словарь), плюрализация своя (`utils/plural.ts`). +Dexie грузится динамически из `useSync`, чтобы не блокировать первый экран. + +Правила, которые держат бюджет: +- `ssr: false` + prerender оболочки: приложение стартует из кэша, без ожидания сети. +- Никаких иконочных шрифтов и веб-шрифтов — системный стек и 12 инлайновых SVG в `components/icons`. +- Никаких chart-библиотек в приложении владельца. Графики есть только в кабинете госагентства — это отдельный вход (`pages/gov`), он грузится своим чанком и не входит в бюджет владельца. +- Dexie импортируется динамически из `useSync`, чтобы не блокировать первый экран. + +## Занятость: почему «Ночи», а не «Сетка» + +Три подхода сравнивались живьём на 360px — файл `NomadCore Занятость 360.dc.html`. +Замер по числу тапов до ответа: + +| Вопрос владельца | День | Сетка | Ночи | +|---|---|---|---| +| «Что у меня сегодня?» | **0** | 2 | 1 | +| «Есть места в субботу?» | 1 | 1 + скролл вбок | **1** | +| «Когда 2 свободные ночи подряд?» | 5+ | 1 + сканирование 140 клеток | **1** | +| «Куда переселить из номера 2?» | 3+ | **1 + скролл** | 2 | +| Одной рукой | да | **нет** | да | + +Вывод, который расходится с ожиданием: на 360px сетка не выигрывает ни одного +вопроса, кроме перемещения между номерами. Её основную работу — «свободно ли +на выходные» — быстрее и одной рукой делает список ночей. Поэтому порядок +режимов: **День** (умолчание) → **Ночи** → **Сетка**. + +Полуинтервал `[checkIn, checkOut)` читается однозначно во всех трёх: +в «Дне» стык подписан словами («Ким Мария → Chen Wei»), в «Сетке» полоса идёт +от середины дня заезда до середины дня выезда (соседние брони касаются, но не +накладываются), а в «Ночах» ошибиться нельзя по построению — строка и есть +ночь, у неё ровно один постоялец. + +## Открытый вопрос к бэкенду + +Спроектированный офлайн-слой **меняет контракт разрешения конфликтов**: +вместо last-write для дат и статуса выбор делает владелец. Это не деталь +вёрстки — требуется работа на стороне API (тело в ответе 409, журнал +изменений с автором, разделение 409/422). Задача, обоснование и разделение +полей: `API-CONFLICTS.md`. До её выполнения экран конфликта работает +в урезанном виде. + +## Структура + +``` +types/index.ts Property, RoomType, Room, Guest, Booking, статусная машина +utils/dates.ts полуинтервалы [checkIn, checkOut), таймзона объекта +lib/db.ts Dexie: кэш GET + очередь мутаций +composables/useSync.ts очередь, ретраи, 409/version, разрешение конфликта +composables/useAvailability.ts сетка номер x дни, «стык» без ложного пересечения +composables/useI18n.ts ru / kg +components/NightsList.vue режим «Ночи» — выбранный второй режим +components/AvailabilityGrid.vue режим «Сетка» — третий, для перемещений +components/*.vue остальные свои компоненты +composables/useNationalStats.ts агрегаты кабинета: всё производное +pages/index.vue Сегодня +pages/occupancy.vue Занятость: День / Ночи / Сетка +pages/booking/-id-.vue Карточка брони +pages/queue.vue Очередь мутаций +pages/gov/occupancy.vue Кабинет госагентства, шкала desk + +../design-system/tokens.css подключается первым; литералов в компонентах нет +``` + +## Доступность и поле + +- Основной текст #111310 на #FFFFFF — 17.4:1. Вторичный #4A4C48 — 8.6:1. +- Тач-таргеты: `min-h-touch` = 44px, основные действия 64px, в нижней трети экрана. +- Переходы только на смену состояния (120–160 мс), `prefers-reduced-motion` уважается. +- Кыргызский длиннее русского: ни одна кнопка не имеет фиксированной ширины, только `min-w`. + +## Одно замечание по именам файлов + +Файловая система этого проекта не хранит квадратные скобки, поэтому динамический +маршрут лежит как `pages/booking/-id-.vue`. При переносе в репозиторий +переименуйте его в `pages/booking/[id].vue` — иначе Nuxt не увидит параметр. diff --git a/nuxt/assets/css/tailwind.css b/nuxt/assets/css/tailwind.css new file mode 100644 index 0000000..db173f2 --- /dev/null +++ b/nuxt/assets/css/tailwind.css @@ -0,0 +1,14 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { -webkit-text-size-adjust: 100%; } + body { @apply bg-canvas text-ink font-sans antialiased; } + /* Цифры в таблицах и сетке не должны прыгать при обновлении */ + .tnum { font-variant-numeric: tabular-nums; } + /* Декоративных анимаций нет; оставшиеся переходы отключаются по системной настройке */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0ms !important; animation: none !important; } + } +} diff --git a/nuxt/components/AppTabBar.vue b/nuxt/components/AppTabBar.vue new file mode 100644 index 0000000..0071e64 --- /dev/null +++ b/nuxt/components/AppTabBar.vue @@ -0,0 +1,28 @@ + + + diff --git a/nuxt/components/AvailabilityGrid.vue b/nuxt/components/AvailabilityGrid.vue new file mode 100644 index 0000000..4646414 --- /dev/null +++ b/nuxt/components/AvailabilityGrid.vue @@ -0,0 +1,75 @@ + + + diff --git a/nuxt/components/BookingList.vue b/nuxt/components/BookingList.vue new file mode 100644 index 0000000..9946e5b --- /dev/null +++ b/nuxt/components/BookingList.vue @@ -0,0 +1,48 @@ + + + diff --git a/nuxt/components/BookingRow.vue b/nuxt/components/BookingRow.vue new file mode 100644 index 0000000..cc6f6f7 --- /dev/null +++ b/nuxt/components/BookingRow.vue @@ -0,0 +1,84 @@ + + + diff --git a/nuxt/components/ConfirmSheet.vue b/nuxt/components/ConfirmSheet.vue new file mode 100644 index 0000000..8a2d172 --- /dev/null +++ b/nuxt/components/ConfirmSheet.vue @@ -0,0 +1,25 @@ + + + diff --git a/nuxt/components/ConflictCard.vue b/nuxt/components/ConflictCard.vue new file mode 100644 index 0000000..99559e7 --- /dev/null +++ b/nuxt/components/ConflictCard.vue @@ -0,0 +1,42 @@ + + + diff --git a/nuxt/components/NightsList.vue b/nuxt/components/NightsList.vue new file mode 100644 index 0000000..5a57584 --- /dev/null +++ b/nuxt/components/NightsList.vue @@ -0,0 +1,86 @@ + + + diff --git a/nuxt/components/StatusPill.vue b/nuxt/components/StatusPill.vue new file mode 100644 index 0000000..858742a --- /dev/null +++ b/nuxt/components/StatusPill.vue @@ -0,0 +1,20 @@ + + + diff --git a/nuxt/components/SyncBar.vue b/nuxt/components/SyncBar.vue new file mode 100644 index 0000000..093b053 --- /dev/null +++ b/nuxt/components/SyncBar.vue @@ -0,0 +1,23 @@ + + + diff --git a/nuxt/composables/useAvailability.ts b/nuxt/composables/useAvailability.ts new file mode 100644 index 0000000..fda70f0 --- /dev/null +++ b/nuxt/composables/useAvailability.ts @@ -0,0 +1,101 @@ +import { cachedGet } from '~/lib/db' +import { addDays, coversNight, eachDay, nights, overlaps } from '~/utils/dates' +import type { Booking, Room } from '~/types' + +const CELL = 44 + +export interface Bar { + booking: Booking + /** Слева/справа в пикселях. Полоса идёт от СЕРЕДИНЫ дня заезда до СЕРЕДИНЫ дня + * выезда, поэтому «встык» даёт касание, а не наложение: [checkIn, checkOut). */ + left: number + width: number +} + +export interface NightRow { + date: string + weekend: boolean + /** Номера, свободные именно в эту НОЧЬ. */ + free: Room[] + full: boolean + occupancy: { room: Room; booking: Booking | null; leaving: Booking | null }[] +} + +/** + * Сетка «номер × дни» из API плюс локальные, ещё не отправленные брони. + * Один источник данных на три режима: День, Ночи, Сетка. + */ +export const useAvailability = (propertyId: string, from: Ref, days = 14) => { + const rooms = ref([]) + const bookings = ref([]) + + const load = async () => { + const key = `availability:${propertyId}:${from.value}:${days}` + const url = `/api/properties/${propertyId}/availability?from=${from.value}&days=${days}` + const data = await cachedGet<{ rooms: Room[]; bookings: Booking[] }>(key, url, fresh => { + rooms.value = fresh.rooms + bookings.value = fresh.bookings + }) + if (data) { rooms.value = data.rooms; bookings.value = data.bookings } + } + watch(from, load, { immediate: true }) + + const active = computed(() => bookings.value.filter(b => b.status !== 'CANCELLED')) + const columns = computed(() => eachDay(from.value, days)) + const windowEnd = computed(() => addDays(from.value, days)) + + /** Бронь, занимающая эту НОЧЬ. Выезд в этот день ночь не занимает. */ + const occupiedOn = (room: Room, day: string) => + active.value.find(b => b.roomId === room.id && coversNight(b, day)) ?? null + + /** Бронь, которая ИМЕННО СЕГОДНЯ выезжает из номера. */ + const leavingOn = (room: Room, day: string) => + active.value.find(b => b.roomId === room.id && b.checkOut === day) ?? null + + /** День, когда один гость выезжает, а другой заезжает в тот же номер. */ + const isTurnDay = (room: Room, day: string) => + !!leavingOn(room, day) && !!active.value.find(b => b.roomId === room.id && b.checkIn === day) + + const freeRooms = (checkIn: string, checkOut: string) => + rooms.value.filter(r => !active.value.some( + b => b.roomId === r.id && overlaps(b, { checkIn, checkOut }) + )) + + /** + * Режим «Ночи» — выбранный по итогам сравнения на 360px. + * Строка = одна ночь, поэтому полуинтервал невозможно прочитать неправильно: + * у ночи ровно один постоялец, «наложения» не существует по построению. + */ + const nightRows = computed(() => + columns.value.map(date => { + const free = freeRooms(date, addDays(date, 1)) + return { + date, + weekend: [0, 6].includes(new Date(date + 'T00:00:00Z').getUTCDay()), + free, + full: free.length === 0, + occupancy: rooms.value.map(room => ({ + room, + booking: occupiedOn(room, date), + leaving: leavingOn(room, date) + })) + } + }) + ) + + /** Режим «Сетка» — третий, для перемещений между номерами. */ + const barsFor = (room: Room): Bar[] => + active.value + .filter(b => b.roomId === room.id && overlaps(b, { checkIn: from.value, checkOut: windowEnd.value })) + .map(b => { + const left = Math.max(0, (nights(from.value, b.checkIn) + 0.5) * CELL) + const right = Math.min((days - 0.5) * CELL, (nights(from.value, b.checkOut) + 0.5) * CELL) + return { booking: b, left: left + 2, width: Math.max(20, right - left - 4) } + }) + + return { + rooms, bookings: active, columns, nightRows, + occupiedOn, leavingOn, isTurnDay, freeRooms, barsFor, + cell: CELL, reload: load + } +} diff --git a/nuxt/composables/useI18n.ts b/nuxt/composables/useI18n.ts new file mode 100644 index 0000000..7222c57 --- /dev/null +++ b/nuxt/composables/useI18n.ts @@ -0,0 +1,55 @@ +import type { Lang } from '~/types' + +/** + * Свой словарь вместо i18n-библиотеки: 120 строк и один реактивный ref + * дешевле 20 КБ рантайма. Кыргызский длиннее русского на 15–30 %, + * поэтому в вёрстке нет фиксированных ширин. + */ +const dict = { + ru: { + today: 'Сегодня', occupancy: 'Занятость', queue: 'Очередь', + checkouts: 'Выезды сегодня', checkins: 'Заезды сегодня', inHouse: 'В доме', awaiting: 'Ждут подтверждения', + // Короткие формы для счётчика на «Сегодня»: он стоит в трёх колонках по 120px, + // и кыргызский вариант должен уместиться туда же. + arrivals: 'Заезды', departures: 'Выезды', waiting: 'Ждут', + priority: 'Сначала', all: 'Все', nothing: 'На сегодня всё сделано', + day: 'День', nights: 'Ночи', grid: 'Сетка', + confirm: 'Подтвердить', checkin: 'Заселить', checkout: 'Выселить', cancelBooking: 'Отменить бронь', + newBooking: 'Новая бронь', free: 'Свободно', notSent: 'Не отправлено', offline: 'Нет сети', + online: 'Сеть есть', allSynced: 'Всё отправлено', sending: 'Отправка…', inQueue: 'в очереди', + documents: 'Документы', noDocuments: 'Не предъявлены', hasDocuments: 'Паспорт предъявлен', + extend: 'Продлить на 1 ночь', earlyCheckout: 'Ранний выезд сегодня', + versionConflict: 'Конфликт версии', keepMine: 'Оставить моё', takeServer: 'Принять серверное', + serverRejected: 'Отказ сервера', otherRoom: 'Другой номер', dropBooking: 'Отклонить бронь', + noFreeRooms: 'Свободных номеров нет', day: 'День', grid: 'Сетка', list: 'Список', + fromTelegram: 'Из Telegram', night: 'ноч.', + status: { HOLD: 'Ожидает', CONFIRMED: 'Подтверждена', CHECKED_IN: 'В доме', CHECKED_OUT: 'Выехал', CANCELLED: 'Отменена' } + }, + kg: { + today: 'Бүгүн', occupancy: 'Бош эместик', queue: 'Кезек', + checkouts: 'Бүгүнкү чыгуулар', checkins: 'Бүгүнкү кирүүлөр', inHouse: 'Үйдө жашагандар', awaiting: 'Ырастоону күтүүдө', + arrivals: 'Кирүү', departures: 'Чыгуу', waiting: 'Күтүүдө', + priority: 'Алгач', all: 'Баары', nothing: 'Бүгүнкү иштер бүттү', + day: 'Күн', nights: 'Түндөр', grid: 'Тор', + confirm: 'Ырастоо', checkin: 'Жайгаштыруу', checkout: 'Чыгаруу', cancelBooking: 'Брондоону жокко чыгаруу', + newBooking: 'Жаңы брондоо', free: 'Бош', notSent: 'Жөнөтүлгөн жок', offline: 'Байланыш жок', + online: 'Байланыш бар', allSynced: 'Баары жөнөтүлдү', sending: 'Жөнөтүлүүдө…', inQueue: 'кезекте', + documents: 'Документтер', noDocuments: 'Көрсөтүлгөн жок', hasDocuments: 'Паспорт көрсөтүлдү', + extend: 'Бир түнгө узартуу', earlyCheckout: 'Бүгүн эрте чыгуу', + versionConflict: 'Версия карама-каршылыгы', keepMine: 'Меникин калтыруу', takeServer: 'Сервердикин алуу', + serverRejected: 'Сервер четке какты', otherRoom: 'Башка бөлмө', dropBooking: 'Брондоону четке кагуу', + noFreeRooms: 'Бош бөлмө жок', day: 'Күн', grid: 'Тор', list: 'Тизме', + fromTelegram: 'Telegram аркылуу', night: 'түн', + status: { HOLD: 'Күтүүдө', CONFIRMED: 'Ырасталган', CHECKED_IN: 'Үйдө', CHECKED_OUT: 'Чыккан', CANCELLED: 'Жокко чыгарылган' } + } +} + +const lang = ref((import.meta.client && (localStorage.getItem('nc.lang') as Lang)) || 'ru') + +watch(lang, v => { if (import.meta.client) localStorage.setItem('nc.lang', v) }) + +export const useI18n = () => ({ + lang, + t: computed(() => dict[lang.value]), + toggle: () => { lang.value = lang.value === 'ru' ? 'kg' : 'ru' } +}) diff --git a/nuxt/composables/useSync.ts b/nuxt/composables/useSync.ts new file mode 100644 index 0000000..e9af7ca --- /dev/null +++ b/nuxt/composables/useSync.ts @@ -0,0 +1,127 @@ +import { db } from '~/lib/db' +import type { Booking, MutationOp, QueuedMutation } from '~/types' + +const online = ref(true) +const queue = ref([]) +const draining = ref(false) + +/** + * Очередь мутаций. Правила: + * 1. Экран никогда не ждёт сеть — изменение применяется локально сразу. + * 2. Порядок внутри одной брони строго сохраняется; сломанная мутация + * блокирует только свою бронь, остальные продолжают уходить. + * 3. Конфликт (409) не решается автоматически. last-write допустим только + * там, где обе версии совместимы (документы, имя, заметки). Даты, статус + * и номер задают физическое размещение людей — там автоматика способна + * выселить живого гостя, поэтому выбор делает владелец. + * + * ВНИМАНИЕ: пункт 3 расходится с текущим контрактом бэкенда («last-write + + * version») и требует его изменения — тело в ответе 409, журнал изменений с + * автором, разделение 409/422. Пока API не отдаёт тело 409, экран конфликта + * деградирует до «повторить или отменить». Подробности и обоснование: + * nuxt/API-CONFLICTS.md + */ +export const useSync = () => { + const refresh = async () => { queue.value = await db.queue.orderBy('createdAt').toArray() } + + const enqueue = async (booking: Booking, body: MutationOp, title: string, subtitle: string) => { + await db.bookings.update(booking.id, { sync: 'pending' }) + await db.queue.add({ + bookingId: booking.id, + baseVersion: booking.version, + body, + title, + subtitle, + createdAt: Date.now(), + attempts: 0, + state: 'pending' + }) + await refresh() + void drain() + } + + const blocked = (m: QueuedMutation) => + queue.value.some(q => q.bookingId === m.bookingId && q.createdAt < m.createdAt && q.state !== 'pending') + + const drain = async () => { + if (!online.value || draining.value) return + draining.value = true + try { + for (const m of queue.value.filter(q => q.state === 'pending')) { + if (blocked(m)) continue + await send(m) + } + } finally { + draining.value = false + } + } + + const send = async (m: QueuedMutation) => { + await db.queue.update(m.id!, { state: 'sending', attempts: m.attempts + 1 }) + await refresh() + try { + const saved = await $fetch('/api/bookings/' + m.bookingId, { + method: 'PATCH', + headers: { 'If-Match': String(m.baseVersion) }, + body: m.body + }) + await db.bookings.put({ ...saved, sync: 'ok' }) + await db.queue.delete(m.id!) + } catch (e: any) { + if (e?.status === 409) { + // Оптимистический лок проиграл: запоминаем оба варианта, решает владелец. + await db.queue.update(m.id!, { state: 'conflict', server: e.data }) + await db.bookings.update(m.bookingId, { sync: 'conflict' }) + } else if (e?.status === 422 || e?.status === 423) { + // Овербукинг: номер заняли с другого устройства, пока мы были офлайн. + await db.queue.update(m.id!, { state: 'rejected', error: e.data?.message }) + await db.bookings.update(m.bookingId, { sync: 'rejected' }) + } else { + // Сеть моргнула — оставляем в очереди, ретрай при следующем drain. + await db.queue.update(m.id!, { state: 'pending' }) + } + } + await refresh() + } + + /** Владелец выбрал своё значение: пересобираем мутацию на свежей версии. */ + const keepMine = async (m: QueuedMutation) => { + await db.queue.update(m.id!, { state: 'pending', baseVersion: m.server!.booking.version, server: undefined }) + await refresh() + void drain() + } + + /** Владелец принял серверное: снимаем мутацию, кладём серверную бронь. */ + const takeServer = async (m: QueuedMutation) => { + await db.bookings.put({ ...m.server!.booking, sync: 'ok' }) + await db.queue.delete(m.id!) + await refresh() + } + + const discard = async (m: QueuedMutation) => { + await db.queue.delete(m.id!) + await db.bookings.update(m.bookingId, { sync: 'ok' }) + await refresh() + } + + onMounted(() => { + online.value = navigator.onLine + const up = () => { online.value = true; void drain() } + const down = () => { online.value = false } + window.addEventListener('online', up) + window.addEventListener('offline', down) + onUnmounted(() => { + window.removeEventListener('online', up) + window.removeEventListener('offline', down) + }) + void refresh().then(drain) + }) + + return { + online, + queue, + pending: computed(() => queue.value.length), + broken: computed(() => queue.value.filter(q => q.state === 'conflict' || q.state === 'rejected').length), + enqueue, drain, keepMine, takeServer, discard, refresh + } +} diff --git a/nuxt/lib/db.ts b/nuxt/lib/db.ts new file mode 100644 index 0000000..9a2ae4a --- /dev/null +++ b/nuxt/lib/db.ts @@ -0,0 +1,43 @@ +import Dexie, { type Table } from 'dexie' +import type { Booking, QueuedMutation } from '~/types' + +/** Ответ GET, положенный в кэш целиком: экран рисуется из него без сети. */ +export interface CachedResponse { + key: string + body: unknown + fetchedAt: number +} + +class NomadDB extends Dexie { + bookings!: Table + cache!: Table + queue!: Table + + constructor() { + super('nomadcore') + this.version(1).stores({ + bookings: 'id, propertyId, roomId, checkIn, checkOut, status, sync', + cache: 'key, fetchedAt', + queue: '++id, bookingId, state, createdAt' + }) + } +} + +export const db = new NomadDB() + +export const putCache = (key: string, body: unknown) => db.cache.put({ key, body, fetchedAt: Date.now() }) +export const readCache = (key: string) => db.cache.get(key).then(r => (r ? (r.body as T) : null)) + +/** + * GET с кэшем: сначала отдаём кэш (мгновенно), потом, если сеть есть, обновляем. + * Офлайн — это штатный режим, а не ошибка, поэтому отсутствие сети не бросает. + */ +export async function cachedGet(key: string, url: string, onFresh?: (v: T) => void): Promise { + const cached = await readCache(key) + if (navigator.onLine) { + $fetch(url) + .then(fresh => { putCache(key, fresh); onFresh?.(fresh) }) + .catch(() => { /* сеть пропала между проверкой и запросом — молча живём на кэше */ }) + } + return cached +} diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts new file mode 100644 index 0000000..6622502 --- /dev/null +++ b/nuxt/nuxt.config.ts @@ -0,0 +1,21 @@ +export default defineNuxtConfig({ + ssr: false, + modules: ['@nuxtjs/tailwindcss', '@vite-pwa/nuxt'], + app: { + head: { + htmlAttrs: { lang: 'ru' }, + meta: [ + { name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' }, + { name: 'theme-color', content: '#111310' } + ] + } + }, + // Оболочка предрендерится и кладётся в кэш: первый экран не ждёт сеть. + nitro: { prerender: { routes: ['/'] } }, + pwa: { + registerType: 'autoUpdate', + workbox: { globPatterns: ['**/*.{js,css,html}'], navigateFallback: '/' }, + manifest: { name: 'NomadCore', short_name: 'NomadCore', display: 'standalone', background_color: '#EDEDEA' } + }, + experimental: { payloadExtraction: false } +}) diff --git a/nuxt/tailwind.config.ts b/nuxt/tailwind.config.ts new file mode 100644 index 0000000..3d68aab --- /dev/null +++ b/nuxt/tailwind.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'tailwindcss' +import { ncPreset } from './design-system/tailwind-preset' + +// Никаких литералов цвета и кегля здесь: единственный источник — tokens.css, +// который preset читает через var(--nc-*). +export default >{ + presets: [ncPreset], + content: ['./components/**/*.vue', './pages/**/*.vue', './composables/**/*.ts'], + corePlugins: { preflight: true } +} diff --git a/nuxt/types/index.ts b/nuxt/types/index.ts new file mode 100644 index 0000000..d64abf7 --- /dev/null +++ b/nuxt/types/index.ts @@ -0,0 +1,91 @@ +export type BookingStatus = 'HOLD' | 'CONFIRMED' | 'CHECKED_IN' | 'CHECKED_OUT' | 'CANCELLED' +export type SyncState = 'ok' | 'pending' | 'conflict' | 'rejected' +export type Lang = 'ru' | 'kg' + +/** Разрешённые переходы. Отмена возможна из любого состояния, кроме терминальных. */ +export const NEXT: Record = { + HOLD: 'CONFIRMED', + CONFIRMED: 'CHECKED_IN', + CHECKED_IN: 'CHECKED_OUT', + CHECKED_OUT: null, + CANCELLED: null +} + +export const CANCELLABLE: BookingStatus[] = ['HOLD', 'CONFIRMED', 'CHECKED_IN'] + +export interface Property { + id: string + name: string + settlement: string + /** Все даты интерпретируются в этой зоне, а не в зоне устройства. */ + timezone: string +} + +export interface RoomType { + id: string + propertyId: string + name: Record + capacity: number + baseRate: number +} + +export interface Room { + id: string + propertyId: string + roomTypeId: string + /** То, что владелец пишет на ключе: «3», «Ю1». */ + label: string +} + +export interface Guest { + id: string + fullName: string + citizenship: string + /** Документы могут появиться позже заселения — это штатная ситуация. */ + documentNo: string | null +} + +export interface Booking { + id: string + code: string + propertyId: string + roomId: string | null + /** Денормализовано из /availability: строка на ключе, её печатают в списках. */ + roomLabel?: string + roomTypeId: string + guest: Guest + /** Полуинтервал [checkIn, checkOut): выезд и заезд в один день не конфликтуют. */ + checkIn: string + checkOut: string + pax: number + rate: number + status: BookingStatus + source: 'app' | 'telegram' + /** Оптимистический лок. Сервер отвечает 409, если version устарела. */ + version: number + /** Локальное поле, на сервер не уходит. */ + sync: SyncState +} + +export type MutationOp = + | { op: 'create'; booking: Booking } + | { op: 'status'; status: BookingStatus } + | { op: 'dates'; checkOut: string } + | { op: 'room'; roomId: string } + | { op: 'documents'; documentNo: string } + +export interface QueuedMutation { + id?: number + bookingId: string + /** Версия, на которой мутация была построена. Уходит в If-Match. */ + baseVersion: number + body: MutationOp + title: string + subtitle: string + createdAt: number + attempts: number + state: 'pending' | 'sending' | 'conflict' | 'rejected' + error?: string + /** Снимок сервера при 409 — показываем владельцу оба варианта. */ + server?: { booking: Booking; changedBy: string; changedAt: string } +} diff --git a/nuxt/utils/dates.ts b/nuxt/utils/dates.ts new file mode 100644 index 0000000..68f2ca4 --- /dev/null +++ b/nuxt/utils/dates.ts @@ -0,0 +1,43 @@ +/** + * Даты броней — календарные, без времени: 'YYYY-MM-DD'. + * Интервал полуоткрытый: [checkIn, checkOut). Ночь принадлежит дню заезда. + * Поэтому выезд 12-го и заезд 12-го в тот же номер — НЕ пересечение. + */ +const DAY = 86400000 + +export const toUTC = (iso: string): number => { + const [y, m, d] = iso.split('-').map(Number) + return Date.UTC(y, m - 1, d) +} + +export const addDays = (iso: string, n: number): string => + new Date(toUTC(iso) + n * DAY).toISOString().slice(0, 10) + +export const nights = (from: string, to: string): number => Math.round((toUTC(to) - toUTC(from)) / DAY) + +export const eachDay = (from: string, count: number): string[] => + Array.from({ length: count }, (_, i) => addDays(from, i)) + +/** Ночь day занята бронью b. */ +export const coversNight = (b: { checkIn: string; checkOut: string }, day: string): boolean => + b.checkIn <= day && day < b.checkOut + +/** Классическая проверка пересечения полуинтервалов. Стык даёт false. */ +export const overlaps = ( + a: { checkIn: string; checkOut: string }, + b: { checkIn: string; checkOut: string } +): boolean => a.checkIn < b.checkOut && b.checkIn < a.checkOut + +/** «Сегодня» в таймзоне ОБЪЕКТА, а не устройства: владелец может быть в роуминге. */ +export const todayIn = (timezone: string): string => + new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }) + .format(new Date()) + +const MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] + +export const shortDate = (iso: string): string => { + const d = new Date(toUTC(iso)) + return d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()] +} + +export const dateRange = (from: string, to: string): string => shortDate(from) + ' → ' + shortDate(to) diff --git a/nuxt/utils/plural.ts b/nuxt/utils/plural.ts new file mode 100644 index 0000000..ec273cd --- /dev/null +++ b/nuxt/utils/plural.ts @@ -0,0 +1,13 @@ +/** + * Русская плюрализация без библиотеки: три формы, шесть строк. + * Кыргызский форм множественного числа для счётных конструкций не требует, + * поэтому в kg-словаре стоит одна форма. + */ +export const plural = (n: number, one: string, few: string, many: string): string => { + const mod10 = n % 10 + const mod100 = n % 100 + if (mod100 > 10 && mod100 < 20) return many + if (mod10 === 1) return one + if (mod10 > 1 && mod10 < 5) return few + return many +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1106f40..bd72702 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,15 @@ importers: specifier: ^3.5.0 version: 3.5.40(typescript@5.9.3) devDependencies: + '@nuxtjs/tailwindcss': + specifier: ^6.14.0 + version: 6.14.0(magicast@0.5.3)(tsx@4.23.1)(yaml@2.9.0) eslint: specifier: ^9.0.0 version: 9.39.5(jiti@2.7.0) + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19(tsx@4.23.1)(yaml@2.9.0) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -96,6 +102,10 @@ importers: packages: + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@angular-devkit/core@17.3.11': resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -676,6 +686,18 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/selector-resolve-nested@3.1.0': + resolution: {integrity: sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + '@dxup/nuxt@0.5.3': resolution: {integrity: sha512-PRwX3kEDjZF4t+j+lWbhFSZ1WBklwFSus5byNtkCL2PgWoUMbywNtewJcUHVSOQdwEYsbD1H/md3e/CKaTvDyw==} @@ -1170,6 +1192,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@koa/router@12.0.2': + resolution: {integrity: sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==} + engines: {node: '>= 12'} + deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved. + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -1390,6 +1417,9 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@nuxtjs/tailwindcss@6.14.0': + resolution: {integrity: sha512-30RyDK++LrUVRgc2A85MktGWIZoRQgeQKjE4CjjD64OXNozyl+4ScHnnYgqVToMM6Ch2ZG2W4wV2J0EN6F0zkQ==} + '@oxc-minify/binding-android-arm-eabi@0.140.0': resolution: {integrity: sha512-z+SVtvvaC+qv1oRC0n7LJ6SIuU8xzCWM+MdQIGyUyeb7W0K3QibUg1J5hPFm+a/49mVS6v5CUpJP/07HEZwTjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2521,6 +2551,9 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2536,6 +2569,9 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2760,6 +2796,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cache-content-type@1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2776,6 +2816,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -2845,6 +2889,10 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -2857,6 +2905,10 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2875,6 +2927,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + comment-json@4.2.5: resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} engines: {node: '>= 6'} @@ -2940,6 +2996,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} @@ -3092,6 +3152,14 @@ packages: supports-color: optional: true + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3101,6 +3169,9 @@ packages: supports-color: optional: true + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3134,10 +3205,17 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3159,10 +3237,16 @@ packages: dexie@4.4.4: resolution: {integrity: sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==} + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3225,6 +3309,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -3552,6 +3640,9 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3647,6 +3738,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -3718,6 +3813,18 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + + http-errors@1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3769,6 +3876,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3838,6 +3952,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3966,6 +4085,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -4003,6 +4126,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -4069,6 +4196,10 @@ packages: jws@3.2.3: resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4083,6 +4214,25 @@ packages: knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-convert@2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + + koa-send@5.0.1: + resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==} + engines: {node: '>= 8'} + + koa-static@5.0.0: + resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==} + engines: {node: '>= 7.6.0'} + + koa@2.16.4: + resolution: {integrity: sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} @@ -4344,6 +4494,9 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4454,6 +4607,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4487,6 +4644,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -4495,10 +4655,17 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + only@0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4572,6 +4739,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4597,6 +4768,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4625,6 +4799,14 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4635,6 +4817,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + portfinder@1.0.38: + resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} + engines: {node: '>= 10.12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4681,6 +4867,36 @@ packages: peerDependencies: postcss: ^8.5.13 + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss-merge-longhand@7.0.7: resolution: {integrity: sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -4717,6 +4933,18 @@ packages: peerDependencies: postcss: ^8.5.13 + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-nesting@13.0.2: + resolution: {integrity: sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + postcss-normalize-charset@7.0.3: resolution: {integrity: sha512-NoBfZu8PR4c2NlmjvrqQTzCzLY79hwcSRgNQ3ZiNK0ABzf9kYKloE/jNj+/8GQY1wsm8pRRgANk6ydLH8cwo0Q==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -4789,6 +5017,10 @@ packages: peerDependencies: postcss: ^8.5.13 + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -4883,6 +5115,9 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -4954,6 +5189,15 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + replace-in-file@6.3.5: + resolution: {integrity: sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==} + engines: {node: '>=10'} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4966,6 +5210,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-path@1.4.0: + resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==} + engines: {node: '>= 0.8'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -5109,6 +5357,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -5196,6 +5447,10 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5292,6 +5547,11 @@ packages: peerDependencies: postcss: ^8.5.13 + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -5321,6 +5581,18 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-config-viewer@2.0.4: + resolution: {integrity: sha512-icvcmdMmt9dphvas8wL40qttrHwAnW3QEN4ExJ2zICjwRsPj7gowd1cOceaWG3IfTuM/cTNGQcx+bsjMtmV+cw==} + engines: {node: '>=13'} + hasBin: true + peerDependencies: + tailwindcss: 1 || 2 || 2.0.1-compat || 3 + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -5394,6 +5666,13 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5449,6 +5728,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -5460,6 +5742,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.23.1: resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} engines: {node: '>=18.0.0'} @@ -6065,6 +6351,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} @@ -6113,10 +6402,18 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yargs@18.0.0: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + ylru@1.4.0: + resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} + engines: {node: '>= 4.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6133,6 +6430,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': dependencies: ajv: 8.12.0 @@ -6881,6 +7180,14 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.4)': + dependencies: + postcss-selector-parser: 7.1.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.4)': + dependencies: + postcss-selector-parser: 7.1.4 + '@dxup/nuxt@0.5.3(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.140.0)(rollup@4.62.2)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.97.1(esbuild@0.28.1)(postcss@8.5.22))': dependencies: '@dxup/unimport': 0.1.2 @@ -7263,6 +7570,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@koa/router@12.0.2': + dependencies: + debug: 4.4.3 + http-errors: 2.0.1 + koa-compose: 4.1.0 + methods: 1.1.2 + path-to-regexp: 6.3.0 + transitivePeerDependencies: + - supports-color + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -7805,6 +8122,30 @@ snapshots: transitivePeerDependencies: - encoding + '@nuxtjs/tailwindcss@6.14.0(magicast@0.5.3)(tsx@4.23.1)(yaml@2.9.0)': + dependencies: + '@nuxt/kit': 3.21.9(magicast@0.5.3) + autoprefixer: 10.5.4(postcss@8.5.22) + c12: 3.3.4(magicast@0.5.3) + consola: 3.4.2 + defu: 6.1.7 + h3: 1.15.11 + klona: 2.0.6 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + postcss: 8.5.22 + postcss-nesting: 13.0.2(postcss@8.5.22) + tailwind-config-viewer: 2.0.4(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)) + tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + ufo: 1.6.4 + unctx: 2.5.0 + transitivePeerDependencies: + - magicast + - supports-color + - tsx + - yaml + '@oxc-minify/binding-android-arm-eabi@0.140.0': optional: true @@ -8744,6 +9085,8 @@ snapshots: ansis@4.3.1: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8775,6 +9118,8 @@ snapshots: - bare-buffer - react-native-b4a + arg@5.0.2: {} + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -9016,6 +9361,11 @@ snapshots: cac@6.7.14: {} + cache-content-type@1.0.1: + dependencies: + mime-types: 2.1.35 + ylru: 1.4.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9035,6 +9385,8 @@ snapshots: callsites@3.1.0: {} + camelcase-css@2.0.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.28.7 @@ -9107,6 +9459,12 @@ snapshots: cli-width@4.1.0: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -9117,6 +9475,8 @@ snapshots: cluster-key-slot@1.1.1: {} + co@4.6.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9129,6 +9489,8 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + comment-json@4.2.5: dependencies: array-timsort: 1.0.3 @@ -9186,6 +9548,11 @@ snapshots: cookie@0.7.2: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + core-js-compat@3.49.0: dependencies: browserslist: 4.28.7 @@ -9337,10 +9704,16 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 + deep-equal@1.0.1: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -9372,8 +9745,12 @@ snapshots: defu@6.1.7: {} + delegates@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} + depd@2.0.0: {} destr@2.0.5: {} @@ -9386,8 +9763,12 @@ snapshots: dexie@4.4.4: {} + didyoumean@1.2.2: {} + diff@8.0.4: {} + dlv@1.1.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -9444,6 +9825,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} enhanced-resolve@5.24.3: @@ -9941,6 +10324,8 @@ snapshots: fs-monkey@1.1.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -10047,6 +10432,15 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -10117,6 +10511,26 @@ snapshots: hookable@6.1.1: {} + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + + http-errors@1.6.3: + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10177,6 +10591,13 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.3: {} + inherits@2.0.4: {} ini@4.1.1: {} @@ -10285,6 +10706,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-document.all@1.0.0: @@ -10392,6 +10815,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -10428,6 +10855,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@1.21.7: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -10495,6 +10924,10 @@ snapshots: jwa: 1.4.2 safe-buffer: 5.2.1 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -10505,6 +10938,56 @@ snapshots: knitwork@1.3.0: {} + koa-compose@4.1.0: {} + + koa-convert@2.0.0: + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + + koa-send@5.0.1: + dependencies: + debug: 4.4.3 + http-errors: 1.8.1 + resolve-path: 1.4.0 + transitivePeerDependencies: + - supports-color + + koa-static@5.0.0: + dependencies: + debug: 3.2.7 + koa-send: 5.0.1 + transitivePeerDependencies: + - supports-color + + koa@2.16.4: + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.9.1 + debug: 4.4.3 + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.8.1 + is-generator-function: 1.1.2 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 @@ -10760,6 +11243,12 @@ snapshots: mute-stream@1.0.0: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.16: {} nanotar@0.3.0: {} @@ -11065,6 +11554,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -11096,6 +11587,10 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -11104,6 +11599,8 @@ snapshots: dependencies: mimic-fn: 4.0.0 + only@0.0.2: {} + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -11113,6 +11610,11 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -11275,6 +11777,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -11295,6 +11799,8 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -11311,6 +11817,10 @@ snapshots: picomatch@4.0.5: {} + pify@2.3.0: {} + + pirates@4.0.7: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -11325,6 +11835,13 @@ snapshots: pluralize@8.0.0: {} + portfinder@1.0.38: + dependencies: + async: 3.2.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + possible-typed-array-names@1.1.0: {} postcss-calc@10.1.1(postcss@8.5.22): @@ -11364,6 +11881,27 @@ snapshots: dependencies: postcss: 8.5.22 + postcss-import@15.1.0(postcss@8.5.22): + dependencies: + postcss: 8.5.22 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.22): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.22 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.22)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.22 + tsx: 4.23.1 + yaml: 2.9.0 + postcss-merge-longhand@7.0.7(postcss@8.5.22): dependencies: postcss: 8.5.22 @@ -11405,6 +11943,18 @@ snapshots: postcss: 8.5.22 postcss-selector-parser: 7.1.4 + postcss-nested@6.2.0(postcss@8.5.22): + dependencies: + postcss: 8.5.22 + postcss-selector-parser: 6.1.4 + + postcss-nesting@13.0.2(postcss@8.5.22): + dependencies: + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) + postcss: 8.5.22 + postcss-selector-parser: 7.1.4 + postcss-normalize-charset@7.0.3(postcss@8.5.22): dependencies: postcss: 8.5.22 @@ -11467,6 +12017,11 @@ snapshots: postcss: 8.5.22 postcss-value-parser: 4.2.0 + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -11550,6 +12105,10 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -11639,12 +12198,25 @@ snapshots: repeat-string@1.6.1: {} + replace-in-file@6.3.5: + dependencies: + chalk: 4.1.2 + glob: 7.2.3 + yargs: 17.7.3 + + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} resolve-from@5.0.0: {} + resolve-path@1.4.0: + dependencies: + http-errors: 1.6.3 + path-is-absolute: 1.0.1 + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -11849,6 +12421,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 + setprototypeof@1.1.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -11932,6 +12506,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@4.2.0: {} @@ -12056,6 +12632,16 @@ snapshots: postcss: 8.5.22 postcss-selector-parser: 7.1.4 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} supports-color@7.2.0: @@ -12082,6 +12668,48 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-config-viewer@2.0.4(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@koa/router': 12.0.2 + commander: 6.2.1 + fs-extra: 9.1.0 + koa: 2.16.4 + koa-static: 5.0.0 + open: 7.4.2 + portfinder: 1.0.38 + replace-in-file: 6.3.5 + tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.22 + postcss-import: 15.1.0(postcss@8.5.22) + postcss-js: 4.1.0(postcss@8.5.22) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.22)(tsx@4.23.1)(yaml@2.9.0) + postcss-nested: 6.2.0(postcss@8.5.22) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + tapable@2.3.3: {} tar-stream@3.2.0: @@ -12152,6 +12780,14 @@ snapshots: transitivePeerDependencies: - react-native-b4a + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + through@2.3.8: {} tiny-invariant@1.3.3: {} @@ -12193,6 +12829,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-interface-checker@0.1.13: {} + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -12208,6 +12846,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsx@4.23.1: dependencies: esbuild: 0.28.1 @@ -12922,6 +13562,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.21.1: {} wsl-utils@0.3.1: @@ -12948,6 +13590,16 @@ snapshots: yargs-parser@22.0.0: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yargs@18.0.0: dependencies: cliui: 9.0.1 @@ -12957,6 +13609,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + ylru@1.4.0: {} + yocto-queue@0.1.0: {} youch-core@0.3.3: