Guest figures
Every name on the book, how many have stayed twice or more, what a guest spends across their stays and how many arrive this week; reads guestsView(query).
Preview
import { CalendarClockIcon, RepeatIcon, UsersIcon, WalletIcon } 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 GuestFigures as Figures } from "../data"
/** The guest book in four numbers, each counted from the bookings under every name. */
export function GuestFigures({ figures }: { figures: Figures }) {
return (
<StatCardGroup data-widget="widget-hotel-guests-guest-figures" columns={4} role="region" aria-label="Guests in figures">
<StatCard
label="Guests"
value={formatNumber(figures.guests)}
description="Every name on the book"
icon={<UsersIcon />}
/>
<StatCard
label="Returning"
value={formatNumber(figures.returning)}
description={`${formatPercent(figures.guests === 0 ? 0 : figures.returning / figures.guests, { maximumFractionDigits: 0 })} have stayed twice or more`}
icon={<RepeatIcon />}
/>
<StatCard
label="Spend per guest"
value={formatCurrency(figures.averageSpendCents / 100, "USD", { maximumFractionDigits: 0 })}
description="Across every stay on the book"
icon={<WalletIcon />}
/>
<StatCard
label="Arriving this week"
value={formatNumber(figures.arriving)}
description="Guests due in over the next seven days"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-guests-guest-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
import { CalendarClockIcon, RepeatIcon, UsersIcon, WalletIcon } 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 GuestFigures as Figures } from "../data"
/** The guest book in four numbers, each counted from the bookings under every name. */
export function GuestFigures({ figures }: { figures: Figures }) {
return (
<StatCardGroup data-widget="widget-hotel-guests-guest-figures" columns={4} role="region" aria-label="Guests in figures">
<StatCard
label="Guests"
value={formatNumber(figures.guests)}
description="Every name on the book"
icon={<UsersIcon />}
/>
<StatCard
label="Returning"
value={formatNumber(figures.returning)}
description={`${formatPercent(figures.guests === 0 ? 0 : figures.returning / figures.guests, { maximumFractionDigits: 0 })} have stayed twice or more`}
icon={<RepeatIcon />}
/>
<StatCard
label="Spend per guest"
value={formatCurrency(figures.averageSpendCents / 100, "USD", { maximumFractionDigits: 0 })}
description="Across every stay on the book"
icon={<WalletIcon />}
/>
<StatCard
label="Arriving this week"
value={formatNumber(figures.arriving)}
description="Guests due in over the next seven days"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}/**
* What this page reads. A guest is a name: every `db.bookings` row under the
* same name is the same guest, and the face is the one the row carries,
* `avatarFor` of that name. `guestsView` groups the book by name, works each
* guest's stays, nights, spend and last and next stay out of their own rows,
* then searches, sorts and pages the list on the server; each row carries the
* guest's bookings, so opening them costs no second read. A stay is a booking
* that held its room — a cancellation or a no-show is on the guest's record
* but is not a stay. "Now" is `REFERENCE_DATE`; a night is a UTC day.
*
* The client islands import only the types below.
*/
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]))
/** One booking under a guest's name, as their record lists it. */
export type GuestBooking = {
id: string
reference: string
room: string
roomType: Room["type"]
checkIn: Date
checkOut: Date
nights: number
totalCents: number
source: Booking["source"]
status: Booking["status"]
}
export type GuestRow = {
/** The guest is the name. */
name: string
avatarUrl: string
stays: number
nights: number
spendCents: number
/** The most recent stay that has begun, by its arrival. */
lastStay?: Date
/** The next stay still to begin, by its arrival. */
nextStay?: Date
/** Every booking under the name, the newest first. */
bookings: GuestBooking[]
}
export type GuestsQuery = {
page: number
pageSize: number
sort: SortSpec
search: string
}
export type GuestFigures = {
guests: number
/** Guests with two stays or more. */
returning: number
/** What a guest's stays come to, on average, in minor units. */
averageSpendCents: number
/** Guests arriving in the seven days from today. */
arriving: number
}
export type GuestsView = { query: GuestsQuery; page: Page<GuestRow>; figures: GuestFigures }
export const PAGE_SIZE = 10
export const DEFAULT_QUERY: GuestsQuery = {
page: 1,
pageSize: PAGE_SIZE,
sort: { id: "spendCents", desc: true },
search: "",
}
/** A cancellation or a no-show holds no room; everything else is a stay. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"
function toBooking(booking: Booking): GuestBooking {
const room = ROOM.get(booking.roomId)
return {
id: booking.id,
reference: booking.reference,
room: room?.number ?? "—",
roomType: room?.type ?? "standard",
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
totalCents: booking.totalCents,
source: booking.source,
status: booking.status,
}
}
/** Every guest on the book, worked out of the bookings under their name. */
function guests(): GuestRow[] {
const byName = new Map<string, Booking[]>()
for (const booking of db.bookings.all()) {
const rows = byName.get(booking.guestName)
if (rows) rows.push(booking)
else byName.set(booking.guestName, [booking])
}
return [...byName.entries()].map(([name, rows]) => {
const stays = rows.filter(held)
const begun = stays.filter((booking) => utcDay(booking.checkIn) <= TODAY).map((booking) => booking.checkIn.getTime())
const ahead = stays.filter((booking) => utcDay(booking.checkIn) > TODAY).map((booking) => booking.checkIn.getTime())
return {
name,
avatarUrl: rows[0].guestAvatar,
stays: stays.length,
nights: stays.reduce((sum, booking) => sum + booking.nights, 0),
spendCents: stays.reduce((sum, booking) => sum + booking.totalCents, 0),
...(begun.length > 0 ? { lastStay: new Date(Math.max(...begun)) } : {}),
...(ahead.length > 0 ? { nextStay: new Date(Math.min(...ahead)) } : {}),
bookings: [...rows].sort((a, b) => b.checkIn.getTime() - a.checkIn.getTime()).map(toBooking),
}
})
}
// A guest with no stay yet, or none still to come, sorts after every guest
// who has one, whichever way the column is turned.
const date = (pick: (row: GuestRow) => Date | undefined) => (a: GuestRow, b: GuestRow, direction: number) => {
const left = pick(a)
const right = pick(b)
if (!left || !right) return Number(!left) - Number(!right)
return direction * (left.getTime() - right.getTime())
}
const number = (pick: (row: GuestRow) => number) => (a: GuestRow, b: GuestRow, direction: number) => direction * (pick(a) - pick(b))
// The table's column ids on the left, how two guests compare on the right.
const SORTS: Record<string, (a: GuestRow, b: GuestRow, direction: number) => number> = {
name: (a, b, direction) => direction * a.name.localeCompare(b.name),
stays: number((row) => row.stays),
nights: number((row) => row.nights),
spendCents: number((row) => row.spendCents),
lastStay: date((row) => row.lastStay),
nextStay: date((row) => row.nextStay),
}
function guestFigures(rows: GuestRow[]): GuestFigures {
const staying = rows.filter((row) => row.stays > 0)
const week = new Set(
db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) >= TODAY && utcDay(booking.checkIn) < TODAY + 7 * DAY)
.map((booking) => booking.guestName)
)
return {
guests: rows.length,
returning: rows.filter((row) => row.stays >= 2).length,
averageSpendCents:
staying.length === 0 ? 0 : Math.round(staying.reduce((sum, row) => sum + row.spendCents, 0) / staying.length),
arriving: week.size,
}
}
/** One page of the guest list for the query the reader has built up, with the figures above it. */
export async function guestsView(query: GuestsQuery): Promise<GuestsView> {
const all = guests()
const needle = query.search.trim().toLowerCase()
const matched = needle ? all.filter((row) => row.name.toLowerCase().includes(needle)) : all
const compare = ownKey(SORTS, query.sort.id) ? SORTS[query.sort.id] : undefined
const direction = query.sort.desc ? -1 : 1
// Ties fall back on the name, so a page never reshuffles between reads.
const sorted = [...matched].sort((a, b) => (compare ? compare(a, b, direction) : 0) || a.name.localeCompare(b.name))
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), total: sorted.length, page, pageSize: size, pageCount },
figures: guestFigures(all),
}
}
/** 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 Guest book page