Skip to contentVibraUI

Booking figures

How full the next thirty nights are and what they are worth, what the last week booked, and the share of the book cancelled; reads bookingsView(query).

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-bookings-booking-figures

Needs the @vibra registry in your components.json — set it up once.

Source

app/hotel/bookings/components/booking-figures.tsx
import { BedDoubleIcon, CalendarPlusIcon, CalendarXIcon, ReceiptIcon } from "lucide-react"

import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type BookFigures } from "../data"

const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })

/**
 * The book in four numbers: how full the next month is, what it is worth, what
 * the last week brought in and what fell away. The island that owns the table
 * hands these down, so a cancellation made from a row moves them too.
 */
export function BookingFigures({ figures }: { figures: BookFigures }) {
  return (
    <StatCardGroup data-widget="widget-hotel-bookings-booking-figures" columns={4} role="region" aria-label="The book in figures">
      <StatCard
        label="Next 30 nights"
        value={formatPercent(figures.nightsSold / figures.nightsAvailable, { maximumFractionDigits: 0 })}
        description={`${formatNumber(figures.nightsSold)} of ${formatNumber(figures.nightsAvailable)} room-nights sold`}
        icon={<BedDoubleIcon />}
      />
      <StatCard
        label="Revenue on the books"
        value={money(figures.revenueCents)}
        description="Room revenue, next 30 nights"
        icon={<ReceiptIcon />}
      />
      <StatCard
        label="New bookings"
        value={String(figures.newBookings)}
        description={`${money(figures.newBookingsCents)} booked in the last 7 days`}
        icon={<CalendarPlusIcon />}
      />
      <StatCard
        label="Cancellations"
        value={formatPercent(figures.total === 0 ? 0 : figures.cancelled / figures.total, { maximumFractionDigits: 1 })}
        description={`${figures.cancelled} of ${formatNumber(figures.total)} bookings`}
        icon={<CalendarXIcon />}
      />
    </StatCardGroup>
  )
}
app/hotel/bookings/data.ts
/**
 * What this page reads. Every row is a `db.bookings` record, and the server
 * pages them: `bookingsView` narrows the book to a window (from today, the
 * past, or all of it), the statuses and room types picked and a search over
 * the guest and the reference, sorts it and cuts one page — and answers with
 * the counts the filters carry and the four figures above the table as well,
 * so a check-in or a cancellation made from a row reaches every number on the
 * page in the one reply. "Now" is `REFERENCE_DATE`; a night is a UTC day.
 *
 * The client islands import only the types below; the words they print are
 * the Hotel dashboard's, in `@/lib/dashboards/hotel/vocabulary`,
 * so `db` never crosses into the browser.
 */
import { formatDate, getInitials } from "@/lib/format"
import {
  REFERENCE_DATE,
  ROOMS,
  db,
  ownKey,
  type Booking,
  type Member,
  type Page,
  type Room,
  type SortSpec,
} from "@/lib/sample-data"

const DAY = 86_400_000

/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

const TODAY = utcDay(REFERENCE_DATE)

const ROOM = new Map(ROOMS.map((room) => [room.id, room]))

export type RoomType = Room["type"]
export type BookingStatus = Booking["status"]

/** Which stretch of the book the table shows: stays not yet over, stays that are, or every one. */
export type BookingWindow = "ahead" | "past" | "all"

/** What the desk can still do to a booking, decided on the server from its status and its dates. */
export type BookingVerb = "checkIn" | "checkOut" | "cancel"

/** One row of the table. */
export type BookingRow = {
  id: string
  reference: string
  guestName: string
  guestAvatar: string
  guests: number
  /** The number on the door. */
  room: string
  roomType: RoomType
  checkIn: Date
  checkOut: Date
  nights: number
  totalCents: number
  source: Booking["source"]
  status: BookingStatus
  verbs: BookingVerb[]
}

export type BookingsQuery = {
  page: number
  pageSize: number
  /** Left out, the window's own order applies: from today soonest first, the past latest first. */
  sort?: SortSpec
  search: string
  window: BookingWindow
  statuses: string[]
  roomTypes: string[]
}

/** The four figures above the table, over the whole book. */
export type BookFigures = {
  /** Room-nights sold over the thirty nights from tonight, and how many the house has. */
  nightsSold: number
  nightsAvailable: number
  revenueCents: number
  /** Bookings made in the seven days to now, and what they come to. */
  newBookings: number
  newBookingsCents: number
  cancelled: number
  total: number
}

export type BookingsView = {
  query: BookingsQuery & { sort: SortSpec }
  page: Page<BookingRow>
  /** How many bookings each window holds. */
  windows: Record<BookingWindow, number>
  /** How many bookings in the current window carry each status, and each room type. */
  statuses: Record<string, number>
  roomTypes: Record<string, number>
  figures: BookFigures
}

export const PAGE_SIZE = 10

export const DEFAULT_QUERY: BookingsQuery = {
  page: 1,
  pageSize: PAGE_SIZE,
  search: "",
  window: "ahead",
  statuses: [],
  roomTypes: [],
}

const WINDOW_SORT: Record<BookingWindow, SortSpec> = {
  ahead: { id: "checkIn", desc: false },
  past: { id: "checkIn", desc: true },
  all: { id: "checkIn", desc: false },
}

const typeOf = (booking: Booking): RoomType => ROOM.get(booking.roomId)?.type ?? "standard"
const doorOf = (booking: Booking): string => ROOM.get(booking.roomId)?.number ?? "—"

/** A stay not yet over leaves today or later; one that is over left before today. */
function inWindow(booking: Booking, window: BookingWindow): boolean {
  if (window === "all") return true
  return window === "ahead" ? utcDay(booking.checkOut) >= TODAY : utcDay(booking.checkOut) < TODAY
}

/**
 * The verbs the desk's actions would accept, so a row never offers one the
 * server refuses: a guest in the house can leave; a confirmed stay can be
 * cancelled, and checked in once its first night has come — but not once its
 * nights have all gone by, which makes it a no-show rather than an arrival.
 */
function verbsFor(booking: Booking): BookingVerb[] {
  if (booking.status === "checked_in") return ["checkOut"]
  if (booking.status !== "confirmed" || utcDay(booking.checkOut) <= TODAY) return []
  return utcDay(booking.checkIn) <= TODAY ? ["checkIn", "cancel"] : ["cancel"]
}

function toRow(booking: Booking): BookingRow {
  return {
    id: booking.id,
    reference: booking.reference,
    guestName: booking.guestName,
    guestAvatar: booking.guestAvatar,
    guests: booking.adults + booking.children,
    room: doorOf(booking),
    roomType: typeOf(booking),
    checkIn: booking.checkIn,
    checkOut: booking.checkOut,
    nights: booking.nights,
    totalCents: booking.totalCents,
    source: booking.source,
    status: booking.status,
    verbs: verbsFor(booking),
  }
}

const text = (pick: (booking: Booking) => string) => (a: Booking, b: Booking) => pick(a).localeCompare(pick(b))
const number = (pick: (booking: Booking) => number) => (a: Booking, b: Booking) => pick(a) - pick(b)

// The table's column ids on the left, how two bookings compare on the right.
const SORTS: Record<string, (a: Booking, b: Booking) => number> = {
  reference: text((booking) => booking.reference),
  guestName: text((booking) => booking.guestName),
  room: number((booking) => Number(doorOf(booking))),
  checkIn: number((booking) => booking.checkIn.getTime()),
  checkOut: number((booking) => booking.checkOut.getTime()),
  nights: number((booking) => booking.nights),
  source: text((booking) => booking.source),
  totalCents: number((booking) => booking.totalCents),
  status: text((booking) => booking.status),
}

function countBy(rows: Booking[], key: (booking: Booking) => string): Record<string, number> {
  const counts: Record<string, number> = {}
  for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1
  return counts
}

/** A cancellation or a no-show holds no room; everything else does. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"

/** The thirty nights from tonight, what the week brought in, and what fell away. */
function bookFigures(book: Booking[]): BookFigures {
  let nightsSold = 0
  let revenueCents = 0
  for (let offset = 0; offset < 30; offset++) {
    const night = TODAY + offset * DAY
    const stays = book.filter((booking) => held(booking) && utcDay(booking.checkIn) <= night && utcDay(booking.checkOut) > night)
    nightsSold += new Set(stays.map((booking) => booking.roomId)).size
    revenueCents += stays.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
  }
  const fresh = book.filter((booking) => booking.bookedAt.getTime() > REFERENCE_DATE.getTime() - 7 * DAY)

  return {
    nightsSold,
    nightsAvailable: ROOMS.length * 30,
    revenueCents,
    newBookings: fresh.length,
    newBookingsCents: fresh.reduce((sum, booking) => sum + booking.totalCents, 0),
    cancelled: book.filter((booking) => booking.status === "cancelled").length,
    total: book.length,
  }
}

/** One page of the book for the query the reader has built up, with everything the page prints around it. */
export async function bookingsView(input: BookingsQuery): Promise<BookingsView> {
  const book = db.bookings.all()
  const query = { ...input, sort: input.sort ?? (ownKey(WINDOW_SORT, input.window) ? WINDOW_SORT[input.window] : WINDOW_SORT.ahead) }
  const window = book.filter((booking) => inWindow(booking, query.window))
  const needle = query.search.trim().toLowerCase()

  const matched = window.filter(
    (booking) =>
      (query.statuses.length === 0 || query.statuses.includes(booking.status)) &&
      (query.roomTypes.length === 0 || query.roomTypes.includes(typeOf(booking))) &&
      (!needle || booking.guestName.toLowerCase().includes(needle) || booking.reference.toLowerCase().includes(needle))
  )
  const compare = ownKey(SORTS, query.sort.id) ? SORTS[query.sort.id] : undefined
  const direction = query.sort.desc ? -1 : 1
  // Array.prototype.sort is stable, so bookings that tie keep the book's own order.
  const sorted = compare ? [...matched].sort((a, b) => direction * compare(a, b)) : matched

  const size = Math.max(1, query.pageSize)
  const pageCount = Math.ceil(sorted.length / size)
  const page = Math.min(Math.max(1, query.page), Math.max(1, pageCount))

  return {
    query: { ...query, page },
    page: {
      rows: sorted.slice((page - 1) * size, page * size).map(toRow),
      total: sorted.length,
      page,
      pageSize: size,
      pageCount,
    },
    windows: {
      ahead: book.filter((booking) => inWindow(booking, "ahead")).length,
      past: book.filter((booking) => inWindow(booking, "past")).length,
      all: book.length,
    },
    statuses: countBy(window, (booking) => booking.status),
    roomTypes: countBy(window, typeOf),
    figures: bookFigures(book),
  }
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever is on the desk. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Bookings page