Rack rates
What each room type asks against what the rooms sold tonight pay, and the house as a whole; reads rackRates().
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-rooms-rack-ratesNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }
}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