Skip to contentVibraUI

Viewing figures

The viewings booked in the next four weeks, those in the next seven days, the busiest day and the agents showing; reads viewingRows().

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-viewings-viewing-figures

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

Source

app/real-estate/viewings/components/viewing-figures.tsx
import { CalendarClockIcon, CalendarDaysIcon, UsersIcon, CalendarRangeIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type ViewingRow } from "../data"

import { DAY_SHORT, dayKey } from "./viewing-vocabulary"

const DAY_MS = 86_400_000

/**
 * The next four weeks in four numbers, counted off the rows the agenda holds,
 * so a move is in the figures as soon as it is on the agenda: the viewings
 * booked, those in the next seven days, the busiest day, and the agents
 * showing.
 */
export function ViewingFigures({ rows, now }: { rows: ViewingRow[]; now: Date }) {
  const week = rows.filter((row) => row.at.getTime() < now.getTime() + 7 * DAY_MS).length
  const byDay = new Map<string, ViewingRow[]>()
  for (const row of rows) byDay.set(dayKey(row.at), [...(byDay.get(dayKey(row.at)) ?? []), row])
  const busiest = [...byDay.values()].sort((a, b) => b.length - a.length || a[0].at.getTime() - b[0].at.getTime())[0]
  const agents = new Set(rows.map((row) => row.agent)).size

  return (
    <StatCardGroup data-widget="widget-real-estate-viewings-viewing-figures" columns={4} role="region" aria-label="The four weeks in figures">
      <StatCard label="Booked" value={formatNumber(rows.length)} description="In the next four weeks" icon={<CalendarRangeIcon />} />
      <StatCard label="This week" value={formatNumber(week)} description="In the next seven days" icon={<CalendarClockIcon />} />
      <StatCard
        label="Busiest day"
        value={busiest ? DAY_SHORT.format(busiest[0].at) : "—"}
        description={busiest ? `${busiest.length} viewing${busiest.length === 1 ? "" : "s"}` : "Nothing booked"}
        icon={<CalendarDaysIcon />}
      />
      <StatCard label="Agents showing" value={formatNumber(agents)} description="With a viewing booked" icon={<UsersIcon />} />
    </StatCardGroup>
  )
}
app/real-estate/viewings/data.ts
/**
 * What /real-estate/viewings reads. A viewing is a `db.listings` row's
 * `nextViewingAt`, on a listing still on the market (listed or under offer):
 * a listing that has sold or let has nothing left to show, whatever its row
 * still carries. The agenda is the next four weeks from `REFERENCE_DATE`, day
 * by day in UTC — the zone viewings are booked in — and each viewing names
 * the `db.members` row showing it.
 *
 * The client islands import only the types below; the days and the slots
 * the reschedule form offers live in `components/viewing-vocabulary.ts`, and
 * the listing's words in the dashboard's vocabulary.
 */
import { isOpen, listingHref } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** How far ahead the agenda reads. */
export const WINDOW_DAYS = 28

/** One booked viewing, and everything the agenda prints about it. */
export type ViewingRow = {
  /** The listing's id: a listing has one viewing booked at a time. */
  id: string
  title: string
  kind: Listing["kind"]
  city: string
  status: Listing["status"]
  agent: string
  agentAvatar?: string
  at: Date
  /** The listing's own page. */
  href: string
}

/**
 * From now to the end of the agenda's last day. The four weeks are days in
 * UTC from today's midnight — the days the agenda heads, the dialog offers
 * and the line under the title names — so a viewing booked at ten on the day
 * after them is out, although it is less than 28 × 24 hours from now.
 */
export function inWindow(at: Date): boolean {
  const time = at.getTime()
  return time >= REFERENCE_DATE.getTime() && time < today().getTime() + WINDOW_DAYS * DAY_MS
}

/** Every viewing booked in the next four weeks, soonest first. */
export function viewingRows(): ViewingRow[] {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return db.listings
    .all()
    .filter((row): row is Listing & { nextViewingAt: Date } => isOpen(row) && row.nextViewingAt instanceof Date)
    .filter((row) => inWindow(row.nextViewingAt))
    .sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime() || a.id.localeCompare(b.id))
    .map((row) => ({
      id: row.id,
      title: row.title,
      kind: row.kind,
      city: row.city,
      status: row.status,
      agent: members.get(row.agentId)?.name ?? "Unassigned",
      agentAvatar: members.get(row.agentId)?.avatarUrl,
      at: row.nextViewingAt,
      href: listingHref(row.id),
    }))
}

/** Midnight UTC on the day of REFERENCE_DATE: the first day of the agenda, and the calendar's today. */
export function today(): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))
}

/** The line under the title: the window, and when the book was read. */
export function lastUpdated(): string {
  const last = new Date(today().getTime() + (WINDOW_DAYS - 1) * DAY_MS)
  const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })
  return `${day(today())} – ${day(last)}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** 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 }))
}
app/real-estate/viewings/components/viewing-vocabulary.ts
/**
 * The days and slots this page prints; a listing's kind and status are the
 * dashboard's words, in `@/lib/dashboards/real-estate/vocabulary`.
 * Vocabulary, not data — it lives beside the islands rather than in
 * `data.ts`, which reads `db` and must never reach the browser. Every date is
 * read in UTC, the zone viewings are booked in, so a viewing at ten is at ten
 * for every reader.
 */
const DAY_MS = 86_400_000

export const DAY_HEADING = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
export const DAY_SHORT = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })

/** "Tue, Sep 8 · 10:30 AM". */
export const slot = (at: Date): string => `${DAY_SHORT.format(at)} · ${TIME.format(at)}`

/** The `yyyy-mm-dd` a day is keyed by — the calendar's own key. */
export const dayKey = (date: Date): string => date.toISOString().slice(0, 10)

/** The days the agenda covers, from `today`, as the reschedule form offers them. */
export function agendaDays(today: Date, count: number): { value: string; label: string }[] {
  return Array.from({ length: count }, (_, index) => {
    const day = new Date(today.getTime() + index * DAY_MS)
    return { value: dayKey(day), label: DAY_SHORT.format(day) }
  })
}

/** The quarter hours from nine to six, when viewings are booked. */
export const TIMES: { value: string; label: string }[] = Array.from({ length: 37 }, (_, index) => {
  const minutes = 9 * 60 + index * 15
  const value = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
  return { value, label: TIME.format(new Date(`1970-01-01T${value}:00.000Z`)) }
})

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 Viewings agenda page