Skip to content
All projectsIn progress

Crystal Ski Rental

Online booking for a ski rental with four stores, a staff front desk and an admin, built and documented the way a client project would be.

Built like client work

Most portfolio projects are todo apps. I wanted one that behaves like a real engagement, for a (fictional) ski rental with four stores in Slovak resorts, and that means the documents come first and the code answers to them.

  1. Brief and requirements

    Functional requirements and business rules with IDs (BR-20: no ski on two rentals at once), so code, tests and seed data can point at the rule they enforce.

    Requirements
  2. A plan

    Stack, architecture decisions, the data model and a commit sequence: foundation, domain and API, screens, quality, deployment.

    Plan
  3. Reviewable commits

    Around ninety small commits, each one coherent on its own, so the history reads like a project a team could review.

    History
  4. An honest gap list

    What a rental taking real customers would still need, from launch blockers (GDPR, payments) to later nice-to-haves, with the decisions only the business can make.

    Production readiness

Three roles, one system

  • Customers

    Find, book, rate

    Search only the skis that are free for your dates, filtered by brand, type, level, length, price and rating. Book several pairs at once with the length-of-rental discount already in the price, cancel, and rate both the rental and the ski model afterwards.

  • Store staff

    Run the front desk

    Today’s pickups and returns, no-shows, anything overdue and a lookup by reservation code. Staff also manage their store’s fleet and customer accounts, and a manager can only change skis in their own store.

  • Admins

    Own the catalogue

    Brands, models, prices, stores and staff accounts. Anything with history is soft-deleted, so reservations keep pointing at it; anything still in use is refused, with a dialog saying what is in the way.

Decisions a reviewer would ask about

The calls that are not obvious from the screens, each written down with its reason in the repository, the way I would leave them for the next developer on a client project.

  • No double booking, guaranteed by the database

    Checking for a clash and then inserting is two statements, and two customers booking the same pair at the same moment can both pass the check. So the rule lives in Postgres as an exclusion constraint over the ski and the date range, partial on items that still hold their dates. The API check only exists to explain the ordinary case; the constraint is what is actually true.

    prisma/migrations/…_init/migration.sql
    ALTER TABLE "reservation_item"
      ADD CONSTRAINT "reservation_item_no_overlap"
      EXCLUDE USING gist (
        "skiId" WITH =,
        -- '[)': start inclusive, end exclusive, matching the rest of the app.
        daterange("startDate", "endDate", '[)') WITH &&
      )
      WHERE ("holdsDates");
  • Money is exact

    Prices are Decimal(10,2) in Postgres, strings in the API and decimal.js in arithmetic; a JavaScript number never holds a price. The search, the basket and the booking all quote through one function, so the price on the card is the price saved, and the reservation keeps a snapshot a later price change can never rewrite.

    src/lib/pricing.ts
    export const DISCOUNT_TIERS = [
      { fromDays: 1, toDays: 3, percent: 0 },
      { fromDays: 4, toDays: 6, percent: 10 },
      { fromDays: 7, toDays: 10, percent: 15 },
      { fromDays: 11, toDays: 19, percent: 20 },
      { fromDays: 20, toDays: 30, percent: 25 },
    ] as const;
  • Rentals are whole days in UTC

    A date picked in a calendar is local midnight, which toISOString turns into the previous day anywhere east of Greenwich. Dates travel as YYYY-MM-DD strings, become UTC at the server, and ranges are half-open, which is what lets one rental end on the day the next begins.

  • Roles are checked twice

    Page guards redirect a signed-in user who is in the wrong area to their own, so a stale bookmark is never a dead end. But pages are not data: every API procedure carries its own role level, and sign-up cannot smuggle in a role.

  • One endpoint per file, over a service

    Each tRPC endpoint says who may call it, what it accepts, and makes one call into a service. Services are wired in a dependency container and never read the session: whoever is acting is passed in, so authorisation stays next to the role check.

  • Rules in pure modules

    The reservation lifecycle and the rating windows are plain functions with table-driven tests. The server enforces them and the screens import the same functions to decide which buttons to show, so a screen never offers an action the server would refuse.

  • View state lives in the URL

    Filters, sorting, pages and tabs are query parameters, so any view can be reloaded, shared and bookmarked. The URL is untrusted input, parsed with the same Zod schema as the API, and a parameter that does not survive is dropped on its own.

Demo data that never goes stale

The seed is a committed snapshot, and nothing in it is dated: every date is an offset from the day of seeding, so the demo always has rentals in the past, today and the coming weeks.

It is also scripted. The demo customer has a rental whose rating is locked, one returned today whose rating is still editable, a family booking coming up and one cancelled, so every screen has something meaningful on it. Seed files refer to each other the way people do (stores by slug, models by "Brand Model", accounts by e-mail), a generator rewrites them the same way every time, and the seeder checks every rule the database cannot enforce before it writes a single row. Demo data can never be a history the app would have refused.

Safe to leave open to anyone

  • Reset every night

    A GitHub Actions workflow migrates and reseeds the demo database at 03:00 UTC, and signs everyone out. The seeder refuses any remote database unless it is explicitly allowed.

  • Rate limits that every instance sees

    Sign-in and sign-up attempts are counted in Postgres rather than in memory, so every serverless instance agrees. Only failures count, so someone who knows their password is never slowed down.

  • E-mail that goes nowhere by accident

    Seeded accounts use reserved domains no server can deliver to, so their mail goes to a capture inbox instead of bouncing. Password resets are never captured: every seeded account shares one password, and the link would hand the account over.

  • Ready for search, kept out of it

    Canonical URLs, Open Graph cards, a sitemap and structured data built from the stores’ addresses and hours, all switched to noindex: the demo describes four stores that do not exist.

  • A database per preview

    Every Vercel preview deployment gets its own Neon database branch, so previews and their migrations never touch the demo’s data.

  • CI gates production

    Lint, types, formatting, unit tests and a production build in one job, the Playwright suite against Postgres in another, and a deployment is only promoted once both pass.

Tested like production

Unit tests where the edges are

Vitest covers every discount tier and the rounding, UTC dates across time zones and a daylight-saving change, every lifecycle transition, the rating window at 59 and at 60 minutes, role rules, pagination and URL parsing.

End to end against a real database

Playwright runs the main flow of every role against a production build and its own database, reseeded before every run, because the overlap constraint, sessions and redirects only exist there. The suite refuses any database that is not local and named *_test, so it can never touch the demo.

App
  • Next.js 16 App Router
  • React 19
  • TypeScript
  • next-intl
UI
  • Tailwind CSS 4
  • shadcn/ui on Base UI
  • React Hook Form
API
  • tRPC 11
  • TanStack Query
  • Zod 4
  • Awilix
Data
  • Prisma 7
  • PostgreSQL 17
  • decimal.js
Auth and mail
  • Better Auth
  • Nodemailer
  • Resend
  • Mailpit
Quality
  • Vitest
  • Playwright
  • ESLint
  • Prettier
  • GitHub Actions
Hosting
  • Vercel
  • Neon

Where it stands

Works today

  • Booking, cancelling and rating for customers, with the length-of-rental discount
  • Front desk, fleet and accounts for staff; catalogue, stores and staff for admins
  • E-mail confirmation and password reset
  • Deployed on Vercel and Neon, reseeded every night

Next up

  • A reworked front desk
  • Photos for the seeded skis in object storage
  • Demo payments with Stripe in test mode

Next project

All projects

MyCards

Personal project · 2026