Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-hotel-guests-guest-figures

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

Source

app/hotel/guests/components/guest-figures.tsx
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>
  )
}
app/hotel/guests/data.ts
/**
 * 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