Skip to content

About

Hotel room booking widget in Vue 3 + TypeScript (strict) — the baseline whose audit surfaced the 13 correctness holes that the Effect-TS and Elm ports close. Atomic-design UI kit, port-adapter data layer. Bilingual EN/RU docs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Booking Widget · Hotel room reservation widget

🇷🇺 Читать на русском

A small SPA widget for booking a hotel room. A portfolio demo — the focus is on readable, well-typed code and a clean architecture rather than on feature count.

Stack: Vue 3 (<script setup>) · TypeScript (strict) · Vite · Vitest. No UI libraries, state managers, or date dependencies — everything runs on native APIs and the Composition API.

🚀 Live demo

Features

  • 📅 Range calendar with check-in/check-out selection, range highlighting, and past-date blocking.
  • 👥 Rooms matched to party size: Standard (up to 2), Comfort (up to 3), Family (up to 5).
  • 🚫 Simulated availability: for the selected room, busy dates are disabled in the calendar.
  • 💰 Nights and total-price calculation; ₽ for the Russian locale, € for English.
  • 🌐 Language switch (ru/en) and 🌗 light/dark theme (respecting the system setting + localStorage).
  • ✅ An editing → review → confirmed flow with validation at each step.
  • ♿ Accessibility: semantic buttons, aria attributes, prefers-reduced-motion.

Getting started

npm install
npm run dev        # dev server
npm run build      # production build (vue-tsc + vite build)
npm test           # unit and component tests (Vitest)
npm run test:e2e   # E2E (Playwright); first run: npx playwright install chromium
npm run typecheck  # type checking
npm run lint       # ESLint + Prettier

Architecture

Logic is separated from presentation — this keeps it easy to test and read.

src/
├─ domain/        pure, Vue-independent logic (easy to test)
│  ├─ types.ts        domain types
│  ├─ date.ts         date helpers on top of the native Date
│  ├─ rooms.ts        room catalogue (mock "server" data)
│  ├─ availability.ts deterministic "busyness" (no randomness → stable tests)
│  └─ booking.ts      nightsBetween / roomsForGuests / calcTotal / validateBooking
├─ api/           data layer behind a port-adapter
│  ├─ bookingApi.ts     port: getRooms() / getRoomCalendar()
│  ├─ mockBookingApi.ts mock with network delay (default)
│  ├─ httpBookingApi.ts real backend (fetch + DTO→domain mapping)
│  └─ index.ts          factory: VITE_API_URL → http, otherwise mock
├─ composables/   reactive layer
│  ├─ useBooking.ts   widget state, shared via provide/inject
│  ├─ useCalendar.ts  month-grid generation and navigation
│  ├─ useI18n.ts      mini-i18n + Intl formatting for prices and dates
│  └─ useTheme.ts     light/dark theme
├─ ui/            UI kit (atomic design) — reusable primitives
│  ├─ UiButton.vue    atom: button (primary / ghost / icon)
│  ├─ UiBadge.vue     atom: inline label (muted / danger / accent)
│  ├─ UiSurface.vue   atom: surface card (polymorphic div/button)
│  ├─ UiStepper.vue   molecule: counter (built from UiButton)
│  ├─ UiSegmented.vue molecule: segmented switch (generic over its type)
│  ├─ UiStack.vue     layout: flex stack with gap on a token scale
│  └─ UiSplit.vue     layout: adaptive two columns → one
├─ components/    organisms — composite components built from ui/
│  ├─ BookingWidget · DateRangePicker · GuestSelector
│  └─ RoomList · RoomCard · BookingSummary · ConfirmationView
├─ assets/rooms/  room photos + image map (kept out of the domain)
└─ tests/         Vitest: domain, store, adapters, UI kit, widget smoke test
e2e/              Playwright: happy-path scenarios in a real browser

Decisions made

  • Atomic design. The widget is assembled from a reusable UI kit (src/ui): atoms (UiButton, UiBadge, UiSurface), molecules (UiStepper, UiSegmented), and layout primitives (UiStack, UiSplit). Organisms (RoomCard, BookingSummary, etc.) are ~80% made of base components — one layer owns the styling of buttons/surfaces/spacing instead of each component doing it on its own.
  • Data layer behind an adapter (Dependency Inversion). The widget loads data through the BookingApi port without knowing where it comes from. Right now a mock with a network delay is used (loading/error states, skeletons); when a real API appears, one line changes in createBookingApi (or just VITE_API_URL) and the UI is untouched. The adapter is injected into the store, so tests swap it for a fake.
  • Availability on room click. Availability isn't guessed up front for all rooms: the user picks dates, clicks a room — and only then is that specific room's calendar loaded (getRoomCalendar), with busy dates highlighted right in the calendar. No need to mark unavailability across all rooms at once, and you can see where a room is free.
  • A composable instead of Pinia. The state is small and scoped to a single widget instance, so provide/inject with a typed InjectionKey is the lighter, more idiomatic fit. The store factory (createBookingStore) is split out and tested without mounting.
  • Vue-free domain layer. Validation and calculations are pure functions: they can be covered by tests without rendering and reused outside the UI.
  • Deterministic "busyness". Busy dates are derived from the date itself, without Math.random, so the demo is reproducible and the tests are stable.
  • No date libraries. A thin wrapper over Date is enough; formatting goes through Intl.

Tests

  • Vitest — 47 tests (npm test): date helpers, domain logic, data adapters (mock + http via a mocked fetch), store (catalogue loading, room calendar, selected-room availability, state transitions), UI kit (UiButton / UiStepper / UiSegmented / UiSteps), widget smoke test.
  • Playwright — 4 E2E (npm run test:e2e): happy path in a real browser — catalogue loading, language/currency switching, highlighting a room's busy dates, and the full booking scenario (room → dates → confirmation).

Type safety: where the compiler doesn't guarantee correctness

This is the baseline for an experiment. The goal is to port the widget to Effect-TS (TEA + foldkit) and Elm and compare how many of the correctness holes below each type system closes structurally (a bad state is unrepresentable / the transition is a compile error) rather than "as long as you remember to be careful." The ports sit alongside this project: ../booking_widget_effect and ../booking_widget_elm (a detailed walkthrough of each hole is in their COMPARISON.md).

Audit: 13 correctness holes

The audit of this version found 13 holes. All of them compile in strict mode: the data type admits an impossible state, a transition rests on a convention, or a rule lives in just one place in the UI. Below is why each one exists specifically in this architecture (Vue refs + Composition API) and how the "make impossible states unrepresentable + a pure update" model closes it.

The data shape admits an impossible state

# Hole Sev. Why it exists here How to close it
4.1 DateRange = { checkIn, checkOut } can hold checkOut while checkIn === null LOW-MED both fields are Date | null independently (domain/types.ts); the type doesn't link them a sum type NoDates | StartOnly | CompleteRange — a half-filled state is unconstructible
4.2 calendarStatus === 'ready' is representable with no selected room LOW calendarStatus and selectedRoomId are independent refs (useBooking.ts); the code defends with a manual selectedRoomId.value !== null check (line 111) nest availability inside the selected room: no room ⇒ no availability field exists
2.1 status: editing|review|confirmed is decoupled from validity MED status is a separate ref (useBooking.ts:33); consistency is held only by the isValid checks in review()/confirm() and conditional rendering a phase sum type whose Reviewing/Confirmed variants carry a ValidBooking (smart constructor) — "confirmed without valid data" is unrepresentable

State transitions and effects rest on convention

# Hole Sev. Why it exists here How to close it
1.1 loadRooms has no request invalidation HIGH loadRooms (useBooking.ts:39) writes its result unconditionally; the requestId guard exists only for the calendar (line 54). A slow/repeated retry overwrites fresh rooms or resets status to error a request-id guard on both loads — discard any result whose id ≠ the model's current id
1.2 the picker's and the calendar's today can diverge across midnight LOW the picker takes new Date() at mount (useCalendar.ts:20), the calendar at fetch time (useBooking.ts:57) a single today, taken once from a startup Flag, used everywhere
2.2 confirm() sets a flag and performs no side effect MED confirm() (useBooking.ts:193) only changes status; the one operation that matters can neither fail nor report an error a real submit command with Submitting/SubmitFailed states, a typed error channel, and retry
6.1 no request cancellation LOW superseded fetches still arrive and are ignored by id (useBooking.ts:62), but not aborted cancellable effects (a fiber / AbortController) — abort superseded work instead of ignoring it
3.1 the booking is spread across 4 refs + 1 reactive MED range (reactive) + guests/selectedRoomId/status/… (refs); cross-field rules hold only because every mutator remembers to call clearRoomCalendar() (setGuests/selectRoom) one immutable Model + one pure update that recomputes a coherent state; there's no second writer

The rule lives only in the UI, not in the domain / type

# Hole Sev. Why it exists here How to close it
5.1 a stay beyond the 180-day fetch horizon is shown as free and bookable HIGH the calendar is fetched for HORIZON_DAYS = 180 (useBooking.ts:18,61); beyond the horizon blockedDates has no data, so .has() = false → "free" the availability window carries from/to; a night outside the window → Unknown (not bookable), and month navigation is bounded by the horizon
5.2 the "no past dates" rule lives only in the calendar UI MED the isPast check is in calendar rendering; validateBooking doesn't reject past dates, so any other path accepts them the rule in a pure validateBooking(today) — it holds for all callers and tests
5.3 a calendar load error = a disabled button, with no hint and no retry MED the calendarStatus === 'error' branch renders no recovery — no banner/retry exhaustive handling of the error state makes skipping the branch a compile error → a visible retry banner

Non-exhaustive matches (silent when a variant is added)

# Hole Sev. Why it exists here How to close it
7.1 non-exhaustive ternaries locale === 'ru' ? … : … MED useI18n.ts:147, DateRangePicker.vue:15, and others; a third locale would silently format as en-GB Match.exhaustive / case over Locale — a new locale = a compile error at every site
7.2 goToStep hardcodes index === 0 ⇒ edit() LOW BookingWidget.vue:36: if (index === 0) store.edit(); reordering the steps silently breaks navigation the step index is derived from the phase via an exhaustive match, not from a position

Port results. Both ports close all 13, with 0 left open. Effect/foldkit — 9 structurally + 4 in practice (the rule is centralized in pure code or enforced by exhaustiveness, but still written by hand). Elm — 10 structurally + 3 in practice. The representation and exhaustiveness holes (4.x, 2.1, 3.1, 7.x, 5.3) are closed cleanly by sum types and a total update; the remaining "in practice" ones (5.2 past dates, 6.1 ignore-vs-abort, 7.2 back-navigation index) are closed by a centralized rule that still has to be written — but forgetting it becomes a compile error.

Beyond the list: what types don't close in principle

These risks aren't among the 13 holes (they aren't about TEA state), yet they're exactly what the type system closes most strongly — and exactly where the Effect version pulls ahead:

  1. The I/O boundary — the main risk. In httpBookingApi: response.json() as RoomDto[], dto.id as RoomId, as string[]. as turns off checking — external data isn't validated. An unknown id (not standard | comfort | family), a missing field, or a different currency compile but blow up at runtime: roomImages[room.id] returns undefined (a broken image) with no type error at all. Closing it: a decoder at the boundary (Elm Json.Decode) — bad input never reaches the domain; in Effect, Schema is the domain type, so the decoder and the type can't drift apart (with hand-written decoders these are still two separate declarations).
  2. Time zones in date keys. toISODateKey uses local getFullYear/Month/Date. Client-side stayNights and server-side blockedDates must live under the same TZ convention; a mismatch (e.g. UTC on the backend) yields an off-by-one day — an invariant, not a type. Closing it: a calendar day as a type with no time/zone (justinmimbs/date in Elm, CalendarDate in foldkit) — a whole class of bugs and the defensive startOfDay normalization disappear.
  3. Logical invariants not expressible by a type: Math.max(...[]) = -Infinity (covered by a manual .length check); blockedDates must match the currently selected room (held by the request token and calendarStatus, not by types); useBookingStore() outside the provider is a runtime error.

Extra strict flags (what each would add)

The project is built in strict mode; the extra flags would tighten it as follows:

  • noUncheckedIndexedAccess (makes arr[i] typed T | undefined) — finds nothing in src/: production code doesn't index arrays "blindly" (only .map / .find / destructuring / Record with finite keys). It only fires in tests.
  • exactOptionalPropertyTypes — produces false positives with withDefaults in Vue (the value is guaranteed by a default, but the type is still | undefined); not worth it for an SFC project.
  • noPropertyAccessFromIndexSignature — pedantic (dataset['theme'], env['BASE_PATH']).

License

MIT

About

Hotel room booking widget in Vue 3 + TypeScript (strict) — the baseline whose audit surfaced the 13 correctness holes that the Effect-TS and Elm ports close. Atomic-design UI kit, port-adapter data layer. Bilingual EN/RU docs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages