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.
- 📅 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 → confirmedflow with validation at each step. - ♿ Accessibility: semantic buttons,
ariaattributes,prefers-reduced-motion.
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 + PrettierLogic 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
- 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
BookingApiport 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 increateBookingApi(or justVITE_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/injectwith a typedInjectionKeyis 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
Dateis enough; formatting goes throughIntl.
- Vitest — 47 tests (
npm test): date helpers, domain logic, data adapters (mock + http via a mockedfetch), 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).
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_effectand../booking_widget_elm(a detailed walkthrough of each hole is in theirCOMPARISON.md).
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.
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:
- The I/O boundary — the main risk. In
httpBookingApi:response.json() as RoomDto[],dto.id as RoomId,as string[].asturns off checking — external data isn't validated. An unknownid(notstandard | comfort | family), a missing field, or a different currency compile but blow up at runtime:roomImages[room.id]returnsundefined(a broken image) with no type error at all. Closing it: a decoder at the boundary (ElmJson.Decode) — bad input never reaches the domain; in Effect,Schemais the domain type, so the decoder and the type can't drift apart (with hand-written decoders these are still two separate declarations). - Time zones in date keys.
toISODateKeyuses localgetFullYear/Month/Date. Client-sidestayNightsand server-sideblockedDatesmust 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/datein Elm,CalendarDatein foldkit) — a whole class of bugs and the defensivestartOfDaynormalization disappear. - Logical invariants not expressible by a type:
Math.max(...[])=-Infinity(covered by a manual.lengthcheck);blockedDatesmust match the currently selected room (held by the request token andcalendarStatus, not by types);useBookingStore()outside the provider is a runtime error.
The project is built in strict mode; the extra flags would tighten it as follows:
noUncheckedIndexedAccess(makesarr[i]typedT | undefined) — finds nothing insrc/: production code doesn't index arrays "blindly" (only.map/.find/ destructuring /Recordwith finite keys). It only fires in tests.exactOptionalPropertyTypes— produces false positives withwithDefaultsin 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']).
MIT