Skip to contentVibraUI

Rack rates

What each room type asks against what the rooms sold tonight pay, and the house as a whole; reads rackRates().

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-rooms-rack-rates

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

Source

app/hotel/rooms/components/rack-rates.tsx
import { ROOM_TYPE_LABELS } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SimpleTable, simpleTableCellVariants } from "@/components/ui/simple-table"
import { TableCell } from "@/components/ui/table"

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

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

/** "5% under rack": how far what a night pays sits from what it asks, in words. */
function againstRack(paid: number, asked: number): string {
  const off = Math.round((1 - paid / asked) * 100)
  if (off === 0) return "At rack"
  return off > 0 ? `${off}% under rack` : `${-off}% over rack`
}

const cell = (align?: "right") => simpleTableCellVariants({ size: "sm", align })

/**
 * What each room type asks and what it got tonight: the rack rate, the rooms
 * of that type sold, the average the stays in them pay and how far under the
 * rack that is — a channel's commission and a corporate rate, read off the
 * night rather than a price list. No state, so it renders on the server.
 */
export function RackRates({ rates }: { rates: RackRate[] }) {
  // The house as one more row: every room, every sale, and the rate the night
  // pays across them against the rack those same rooms ask.
  const rooms = rates.reduce((sum, rate) => sum + rate.rooms, 0)
  const sold = rates.reduce((sum, rate) => sum + rate.sold, 0)
  const paid = rates.reduce((sum, rate) => sum + rate.paidCents, 0)
  const asked = rates.reduce((sum, rate) => sum + rate.rackRateCents * rate.sold, 0)

  return (
    <Card data-widget="widget-hotel-rooms-rack-rates" role="region" aria-label="Rack rates" className="h-full">
      <CardHeader>
        <CardTitle>Rack rates</CardTitle>
        <CardDescription>What each room type asks, and what the rooms sold tonight pay.</CardDescription>
      </CardHeader>
      <CardContent className="p-0">
        <SimpleTable
          size="sm"
          rows={rates}
          rowKey="type"
          columns={[
            {
              key: "type",
              header: "Room type",
              cell: (rate) => (
                <span className="flex flex-col">
                  <span className="font-medium">{ROOM_TYPE_LABELS[rate.type]}</span>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {`${rate.rooms} rooms · ${rate.beds === 1 ? "1 bed" : `${rate.beds} beds`}`}
                  </span>
                </span>
              ),
            },
            { key: "rackRateCents", header: "Rack", align: "right", cell: (rate) => <span className="tabular-nums">{money(rate.rackRateCents)}</span> },
            {
              key: "sold",
              header: "Sold tonight",
              align: "right",
              cell: (rate) => <span className="whitespace-nowrap tabular-nums">{`${rate.sold} of ${rate.rooms}`}</span>,
            },
            {
              key: "averageCents",
              header: "Paid tonight",
              align: "right",
              cell: (rate) =>
                rate.sold === 0 ? (
                  <span className="text-muted-foreground">—</span>
                ) : (
                  <span className="flex flex-col items-end">
                    <span className="tabular-nums">{money(rate.averageCents)}</span>
                    <span className="text-xs whitespace-nowrap text-muted-foreground tabular-nums">
                      {againstRack(rate.averageCents, rate.rackRateCents)}
                    </span>
                  </span>
                ),
            },
          ]}
          footer={
            <>
              <TableCell className={cell()}>
                <span className="flex flex-col">
                  <span className="font-medium">The house</span>
                  <span className="text-xs font-normal text-muted-foreground tabular-nums">{`${rooms} rooms`}</span>
                </span>
              </TableCell>
              <TableCell className={cell("right")} />
              <TableCell className={cell("right")}>{`${sold} of ${rooms}`}</TableCell>
              <TableCell className={cell("right")}>
                {sold === 0 ? (
                  "—"
                ) : (
                  <span className="flex flex-col items-end">
                    <span>{money(paid / sold)}</span>
                    <span className="text-xs font-normal whitespace-nowrap text-muted-foreground">{againstRack(paid, asked)}</span>
                  </span>
                )}
              </TableCell>
            </>
          }
        />
      </CardContent>
    </Card>
  )
}
app/hotel/rooms/data.ts
/**
 * What this page reads: the house's own `ROOMS` and `db.bookings`. For every
 * room it finds the stays that touch today — the one arriving, the one staying
 * over, the one leaving — and the next one due, and hands those slots to the
 * floor plan, which reads each room's state off them with `roomState` in
 * `vocabulary.ts`; the same rule runs here for the header's count. Only a
 * stay that still holds the room counts: a cancellation, a no-show or a stay
 * already checked out holds none. "Now" is `REFERENCE_DATE`, tonight the
 * night that begins on its UTC day.
 *
 * The client islands import only the types; `db` never crosses into the browser.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, ROOMS, db, type Booking, type Member } from "@/lib/sample-data"

import { isSold, roomState, type RoomSlot, type RoomType, type SlotStay } from "./vocabulary"

/** 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())

/** Tonight: the night that begins on the reference date's own UTC day. */
const TONIGHT = utcDay(REFERENCE_DATE)

const holds = (booking: Booking) => booking.status === "confirmed" || booking.status === "checked_in"

function toStay(booking: Booking): SlotStay {
  return {
    id: booking.id,
    reference: booking.reference,
    guestName: booking.guestName,
    guestAvatar: booking.guestAvatar,
    checkIn: booking.checkIn,
    checkOut: booking.checkOut,
    nights: booking.nights,
    ratePerNightCents: booking.ratePerNightCents,
    status: booking.status,
  }
}

/** Every room in the house, door order, with the stays that touch today and the next one due. */
export function floorPlan(): RoomSlot[] {
  const book = db.bookings.all().filter(holds)

  return ROOMS.map((room) => {
    const stays = book.filter((booking) => booking.roomId === room.id)
    const next = stays
      .filter((booking) => utcDay(booking.checkIn) > TONIGHT)
      .sort((a, b) => a.checkIn.getTime() - b.checkIn.getTime())[0]
    const arrival = stays.find((booking) => utcDay(booking.checkIn) === TONIGHT)
    const stayover = stays.find((booking) => utcDay(booking.checkIn) < TONIGHT && utcDay(booking.checkOut) > TONIGHT)
    const departure = stays.find((booking) => utcDay(booking.checkOut) === TONIGHT)

    return {
      id: room.id,
      number: room.number,
      type: room.type,
      floor: room.floor,
      beds: room.beds,
      rackRateCents: room.rackRateCents,
      ...(arrival ? { arrival: toStay(arrival) } : {}),
      ...(stayover ? { stayover: toStay(stayover) } : {}),
      ...(departure ? { departure: toStay(departure) } : {}),
      ...(next ? { next: toStay(next) } : {}),
    }
  })
}

export type RackRate = {
  type: RoomType
  rooms: number
  beds: number
  rackRateCents: number
  /** Rooms of this type someone sleeps in tonight. */
  sold: number
  /** What the stays in them pay tonight, in all and on average, in minor units; zero when none is sold. */
  paidCents: number
  averageCents: number
}

/** What each room type asks, and what the stays in it tonight actually pay. */
export function rackRates(): RackRate[] {
  const tonight = db.bookings
    .all()
    .filter((booking) => holds(booking) && utcDay(booking.checkIn) <= TONIGHT && utcDay(booking.checkOut) > TONIGHT)

  return (["standard", "deluxe", "suite"] as const).map((type) => {
    const rooms = ROOMS.filter((room) => room.type === type)
    const ids = new Set(rooms.map((room) => room.id))
    const stays = tonight.filter((booking) => ids.has(booking.roomId))
    const paid = stays.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
    return {
      type,
      rooms: rooms.length,
      beds: rooms[0].beds,
      rackRateCents: rooms[0].rackRateCents,
      sold: new Set(stays.map((booking) => booking.roomId)).size,
      paidCents: paid,
      averageCents: stays.length === 0 ? 0 : Math.round(paid / stays.length),
    }
  })
}

const NIGHT = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })

/** "Friday, September 4": the night the plan is of. */
export function nightName(): string {
  return NIGHT.format(new Date(TONIGHT))
}

/** The line under the title: which night, and how much of the house is sold for it. */
export function tonightLine(): string {
  const sold = floorPlan().filter((slot) => isSold(roomState(slot))).length
  return `${nightName()} · ${sold} of ${ROOMS.length} rooms sold`
}

/** 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 }
}
app/hotel/rooms/vocabulary.ts
import { type Booking, type Room } from "@/lib/sample-data"

/**
 * The shape of a room tonight, the rule that reads its state and the words
 * the page prints for a state. Plain types and functions with no rows behind
 * them — the entity types above are erased — so `data.ts` builds the slots on
 * the server and the floor plan re-reads each slot's state in the browser
 * after a desk action: one rule for both, so a tile never disagrees with the
 * count beside it. The words for a room type and a night are the Hotel
 * dashboard's, in `@/lib/dashboards/hotel/vocabulary`.
 */

export type RoomType = Room["type"]

export type StayStatus = Booking["status"]

/** One stay as the room rack shows it. */
export type SlotStay = {
  id: string
  reference: string
  guestName: string
  guestAvatar: string
  checkIn: Date
  checkOut: Date
  nights: number
  ratePerNightCents: number
  status: StayStatus
}

/** A room and the stays that touch today: the one arriving, the one staying over, the one leaving, and the next. */
export type RoomSlot = {
  id: string
  /** The number on the door. */
  number: string
  type: RoomType
  floor: number
  beds: number
  rackRateCents: number
  arrival?: SlotStay
  stayover?: SlotStay
  departure?: SlotStay
  next?: SlotStay
}

export type RoomState = "arriving" | "occupied" | "departing" | "free"

/** A stay that still holds the room: booked, or in it. A cancellation, a no-show or a finished stay does not. */
export function live(stay: SlotStay | undefined): SlotStay | undefined {
  return stay && (stay.status === "confirmed" || stay.status === "checked_in") ? stay : undefined
}

/**
 * Tonight's state of a room. A guest due in today who has not reached the
 * desk makes it arriving; someone sleeping in it tonight — a stay-over, or
 * today's arrival once checked in — makes it occupied; a guest leaving today
 * with no one behind them makes it departing; anything else is free.
 */
export function roomState(slot: RoomSlot): RoomState {
  const arrival = live(slot.arrival)
  if (arrival?.status === "confirmed") return "arriving"
  if (arrival || live(slot.stayover)) return "occupied"
  if (live(slot.departure)) return "departing"
  return "free"
}

/** Sold tonight: someone sleeps in the room, whether already in it or on the way. */
export const isSold = (state: RoomState) => state === "arriving" || state === "occupied"

/** The legend's order: tonight's house, then today's movement, then what is left. */
export const STATES: RoomState[] = ["occupied", "arriving", "departing", "free"]

export const STATE_LABELS: Record<RoomState, string> = {
  arriving: "Arriving",
  occupied: "Occupied",
  departing: "Departing",
  free: "Free",
}

export const STATE_HINTS: Record<RoomState, string> = {
  arriving: "Due in today",
  occupied: "In the room tonight",
  departing: "Due out today",
  free: "To sell tonight",
}

/** The floors top down, the way the building stands. */
export const FLOORS = [3, 2, 1]

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 Room rack page