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
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-bookings-booking-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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