Availability
The month's nights, each filled by how many rooms are left, and the rooms free on the night picked; reads availability().
Preview
"use client"
import * as React from "react"
import { AvailabilityCalendar } from "@/components/ui/availability-calendar"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { SectionHeader } from "@/components/ui/section-header"
import { type AvailabilityDay } from "../data"
export type AvailabilityPanelProps = {
month: Date
rooms: number
days: AvailabilityDay[]
/** Tonight, at midnight UTC — the day the calendar opens on. */
today: Date
}
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })
/** How full a night reads: none left is full, a third or fewer is limited. */
function fillFor(free: number, rooms: number): "available" | "partial" | "full" {
if (free <= 0) return "full"
return free <= rooms / 3 ? "partial" : "available"
}
/**
* The month's availability. The three fills are a rule over the number of
* rooms free, not a colour picked per day, and the line under the calendar
* says the number itself — colour is never the only encoding.
*/
export function AvailabilityPanel({ month, rooms, days, today }: AvailabilityPanelProps) {
const [shown, setShown] = React.useState(month)
const [picked, setPicked] = React.useState<Date | undefined>(today)
const free = React.useMemo(
() => new Map(days.map((day) => [day.key, day.free])),
[days]
)
const marks = React.useMemo(() => {
const out: Record<string, MiniCalendarMark> = {}
for (const day of days) out[day.key] = { fill: fillFor(day.free, rooms) }
return out
}, [days, rooms])
const key = picked?.toISOString().slice(0, 10)
const left = key !== undefined ? free.get(key) : undefined
return (
<Card data-widget="widget-hotel-overview-availability-panel">
<CardHeader>
<SectionHeader as="h2" title="Availability" description="Rooms free, night by night." />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<AvailabilityCalendar
month={shown}
onMonthChange={setShown}
value={picked}
onValueChange={setPicked}
marks={marks}
today={today}
legendLabels={{ full: "Sold out" }}
className="w-full"
/>
<p data-slot="availability-readout" className="text-xs text-muted-foreground">
{picked === undefined
? "Pick a night."
: left === undefined
? `${FULL.format(picked)} is outside the book.`
: `${FULL.format(picked)} — ${left} of ${rooms} rooms free${left === 0 ? ", the house is sold out" : ""}.`}
</p>
</CardContent>
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-overview-availability-panelNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { AvailabilityCalendar } from "@/components/ui/availability-calendar"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { SectionHeader } from "@/components/ui/section-header"
import { type AvailabilityDay } from "../data"
export type AvailabilityPanelProps = {
month: Date
rooms: number
days: AvailabilityDay[]
/** Tonight, at midnight UTC — the day the calendar opens on. */
today: Date
}
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })
/** How full a night reads: none left is full, a third or fewer is limited. */
function fillFor(free: number, rooms: number): "available" | "partial" | "full" {
if (free <= 0) return "full"
return free <= rooms / 3 ? "partial" : "available"
}
/**
* The month's availability. The three fills are a rule over the number of
* rooms free, not a colour picked per day, and the line under the calendar
* says the number itself — colour is never the only encoding.
*/
export function AvailabilityPanel({ month, rooms, days, today }: AvailabilityPanelProps) {
const [shown, setShown] = React.useState(month)
const [picked, setPicked] = React.useState<Date | undefined>(today)
const free = React.useMemo(
() => new Map(days.map((day) => [day.key, day.free])),
[days]
)
const marks = React.useMemo(() => {
const out: Record<string, MiniCalendarMark> = {}
for (const day of days) out[day.key] = { fill: fillFor(day.free, rooms) }
return out
}, [days, rooms])
const key = picked?.toISOString().slice(0, 10)
const left = key !== undefined ? free.get(key) : undefined
return (
<Card data-widget="widget-hotel-overview-availability-panel">
<CardHeader>
<SectionHeader as="h2" title="Availability" description="Rooms free, night by night." />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<AvailabilityCalendar
month={shown}
onMonthChange={setShown}
value={picked}
onValueChange={setPicked}
marks={marks}
today={today}
legendLabels={{ full: "Sold out" }}
className="w-full"
/>
<p data-slot="availability-readout" className="text-xs text-muted-foreground">
{picked === undefined
? "Pick a night."
: left === undefined
? `${FULL.format(picked)} is outside the book.`
: `${FULL.format(picked)} — ${left} of ${rooms} rooms free${left === 0 ? ", the house is sold out" : ""}.`}
</p>
</CardContent>
</Card>
)
}/**
* What this page reads. Every stay comes from `db.bookings` and every room
* from the house's own `ROOMS`; nothing here is written out, and "now" is
* `REFERENCE_DATE` — tonight is the night that starts on its UTC day, which is
* what occupancy, ADR and RevPAR are all measured over.
*
* The client islands import only the types below; `db` never crosses into the
* browser.
*/
import { SOURCE_LABELS, SOURCE_ORDER } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency, formatPercent, getInitials } from "@/lib/format"
import { REFERENCE_DATE, ROOMS, db, type Booking, type Member, type Room } from "@/lib/sample-data"
const DAY = 86_400_000
/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
function utcDay(date: Date): number {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
/** Tonight: the night that begins on the reference date's own UTC day. */
export const TONIGHT = utcDay(REFERENCE_DATE)
const ROOM = new Map(ROOMS.map((room) => [room.id, room]))
export type StayStatus = Booking["status"]
export type StayRow = {
id: string
reference: string
guestName: string
guestAvatar: string
room: string
roomType: Room["type"]
status: StayStatus
checkIn: Date
checkOut: Date
nights: number
guests: number
ratePerNightCents: number
totalCents: number
source: Booking["source"]
}
/** One stay as this page reads it. */
function toRow(booking: Booking): StayRow {
const room = ROOM.get(booking.roomId)
return {
id: booking.id,
reference: booking.reference,
guestName: booking.guestName,
guestAvatar: booking.guestAvatar,
room: room?.number ?? "—",
roomType: room?.type ?? "standard",
status: booking.status,
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
guests: booking.adults + booking.children,
ratePerNightCents: booking.ratePerNightCents,
totalCents: booking.totalCents,
source: booking.source,
}
}
/** A cancellation or a no-show holds no room; everything else does. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"
/** The stays that cover the night beginning at `night`. */
function occupying(night: number): Booking[] {
return db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) <= night && utcDay(booking.checkOut) > night)
}
export type FrontDeskStat = { id: string; label: string; value: string; description: string }
/** The four numbers a duty manager reads first, all over tonight. */
export function frontDeskStats(): FrontDeskStat[] {
const tonight = occupying(TONIGHT)
const sold = new Set(tonight.map((booking) => booking.roomId)).size
const revenue = tonight.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
const arrivals = db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) === TONIGHT)
const adr = tonight.length > 0 ? revenue / tonight.length : 0
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
return [
{
id: "occupancy",
label: "Occupancy",
value: formatPercent(sold / ROOMS.length, { maximumFractionDigits: 0 }),
description: `${sold} of ${ROOMS.length} rooms sold`,
},
{ id: "adr", label: "ADR", value: money(adr), description: "Average daily rate tonight" },
{
id: "revpar",
label: "RevPAR",
value: money(revenue / ROOMS.length),
description: "Revenue per available room",
},
{
id: "arrivals",
label: "Arrivals",
value: String(arrivals.length),
description: `${arrivals.filter((booking) => booking.status === "confirmed").length} still to check in`,
},
]
}
export type GuestFlow = { arrivals: StayRow[]; inHouse: StayRow[]; departures: StayRow[] }
/** Who is coming in, who is here, and who is going out — today. */
export function guestFlow(): GuestFlow {
const rows = db.bookings.all().filter(held)
const by = (a: StayRow, b: StayRow) => a.room.localeCompare(b.room)
return {
arrivals: rows.filter((booking) => utcDay(booking.checkIn) === TONIGHT).map(toRow).sort(by),
inHouse: rows
.filter(
(booking) => utcDay(booking.checkIn) < TONIGHT && utcDay(booking.checkOut) > TONIGHT
)
.map(toRow)
.sort(by),
departures: rows.filter((booking) => utcDay(booking.checkOut) === TONIGHT).map(toRow).sort(by),
}
}
export type AvailabilityDay = { key: string; free: number }
/** How many rooms are free each night of the month tonight falls in. */
export function availability(): { month: Date; rooms: number; days: AvailabilityDay[] } {
const start = new Date(TONIGHT)
const first = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)
const length = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate()
const days = Array.from({ length }, (_, index) => {
const night = first + index * DAY
const sold = new Set(occupying(night).map((booking) => booking.roomId)).size
return { key: new Date(night).toISOString().slice(0, 10), free: ROOMS.length - sold }
})
return { month: new Date(first), rooms: ROOMS.length, days }
}
export type SourceGrid = { rows: string[]; columns: string[]; values: number[][] }
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** Arrivals per channel over the eight weeks around tonight, Monday to Monday. */
export function sourceGrid(): SourceGrid {
const monday = TONIGHT - ((new Date(TONIGHT).getUTCDay() + 6) % 7) * DAY
const weeks = Array.from({ length: 8 }, (_, index) => monday + (index - 4) * 7 * DAY)
const rows = db.bookings.all().filter(held)
return {
rows: SOURCE_ORDER.map((source) => SOURCE_LABELS[source]),
columns: weeks.map((week) => WEEK_LABEL.format(new Date(week))),
values: SOURCE_ORDER.map((source) =>
weeks.map(
(week) =>
rows.filter(
(booking) =>
booking.source === source &&
utcDay(booking.checkIn) >= week &&
utcDay(booking.checkIn) < week + 7 * DAY
).length
)
),
}
}
/** The line under the title: tonight's shape in one sentence. */
export function deskSummary(): string {
const { arrivals, inHouse, departures } = guestFlow()
return `${arrivals.length} arrivals · ${inHouse.length} in house · ${departures.length} departures`
}
/** 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 Front desk page