Book figures
What is on the market and under offer, what it asks and the median ask, what has closed — sold and let — and the enquiry rate its views turned into; reads bookTotals().
Preview
import { BuildingIcon, HandshakeIcon, MessageSquareIcon, TagIcon } from "lucide-react"
import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatCompact, formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BookTotals } from "../data"
/**
* The book in four numbers: what is on the market and what it asks, what has
* closed, and how many views became an enquiry. No state, so it renders on the
* server.
*/
export function BookFigures({ totals }: { totals: BookTotals }) {
const rate = totals.views === 0 ? 0 : totals.enquiries / totals.views
return (
<StatCardGroup data-widget="widget-real-estate-listings-book-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="On the market"
value={formatNumber(totals.open)}
description={`${formatNumber(totals.underOffer)} under offer`}
icon={<BuildingIcon />}
/>
<StatCard
label="Asking"
value={formatCurrency(totals.askingCents / 100, "USD", { compact: true })}
description={`Median ${money(totals.medianCents)}`}
icon={<TagIcon />}
/>
<StatCard
label="Closed"
value={formatNumber(totals.sold + totals.rented)}
description={`${formatNumber(totals.sold)} sold · ${formatNumber(totals.rented)} let`}
icon={<HandshakeIcon />}
/>
<StatCard
label="Enquiry rate"
value={formatPercent(rate, { maximumFractionDigits: 1 })}
description={`${formatNumber(totals.enquiries)} enquiries from ${formatCompact(totals.views)} views`}
icon={<MessageSquareIcon />}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-listings-book-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
import { BuildingIcon, HandshakeIcon, MessageSquareIcon, TagIcon } from "lucide-react"
import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatCompact, formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BookTotals } from "../data"
/**
* The book in four numbers: what is on the market and what it asks, what has
* closed, and how many views became an enquiry. No state, so it renders on the
* server.
*/
export function BookFigures({ totals }: { totals: BookTotals }) {
const rate = totals.views === 0 ? 0 : totals.enquiries / totals.views
return (
<StatCardGroup data-widget="widget-real-estate-listings-book-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="On the market"
value={formatNumber(totals.open)}
description={`${formatNumber(totals.underOffer)} under offer`}
icon={<BuildingIcon />}
/>
<StatCard
label="Asking"
value={formatCurrency(totals.askingCents / 100, "USD", { compact: true })}
description={`Median ${money(totals.medianCents)}`}
icon={<TagIcon />}
/>
<StatCard
label="Closed"
value={formatNumber(totals.sold + totals.rented)}
description={`${formatNumber(totals.sold)} sold · ${formatNumber(totals.rented)} let`}
icon={<HandshakeIcon />}
/>
<StatCard
label="Enquiry rate"
value={formatPercent(rate, { maximumFractionDigits: 1 })}
description={`${formatNumber(totals.enquiries)} enquiries from ${formatCompact(totals.views)} views`}
icon={<MessageSquareIcon />}
/>
</StatCardGroup>
)
}/**
* What /real-estate/listings reads. The book is `db.listings`; each listing's
* agent is the `db.members` row it names. The four figures are rules over the
* rows: what is on the market (listed or under offer) and what it asks, what
* has closed, and how many of the views turned into an enquiry. "Now" is
* `REFERENCE_DATE`.
*
* The client islands import only the types below; `db` never reaches the
* browser. The words and the money formatter the islands print with are the
* dashboard's own, in `@/lib/dashboards/real-estate/vocabulary`.
*/
import { formatDate, getInitials } from "@/lib/format"
import { isOpen, listingHref, median } from "@/lib/dashboards/real-estate/vocabulary"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"
/** One listing as the table and the tiles print it. */
export type ListingRow = {
id: string
title: string
kind: Listing["kind"]
city: string
country: string
priceCents: number
beds: number
baths: number
sqm: number
status: Listing["status"]
agent: string
agentAvatar?: string
views: number
enquiries: number
listedAt: Date
/** The listing's own page. */
href: string
}
/** Every listing on the book; the island sorts, filters and pages them itself. */
export function listingRows(): ListingRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.listings.all().map((row) => ({
id: row.id,
title: row.title,
kind: row.kind,
city: row.city,
country: row.country,
priceCents: row.priceCents,
beds: row.beds,
baths: row.baths,
sqm: row.sqm,
status: row.status,
agent: members.get(row.agentId)?.name ?? "Unassigned",
agentAvatar: members.get(row.agentId)?.avatarUrl,
views: row.views,
enquiries: row.enquiries,
listedAt: row.listedAt,
href: listingHref(row.id),
}))
}
/** Every city the book holds a listing in, A to Z, for the facet. */
export function cities(): string[] {
return [...new Set(db.listings.all().map((row) => row.city))].sort((a, b) => a.localeCompare(b))
}
export type BookTotals = {
/** Listed or under offer. */
open: number
underOffer: number
/** What the open listings ask between them, and the middle one of them, in cents. */
askingCents: number
medianCents: number
sold: number
rented: number
views: number
enquiries: number
}
/** The four figures over the book. */
export function bookTotals(): BookTotals {
const rows = db.listings.all()
const open = rows.filter(isOpen)
return {
open: open.length,
underOffer: rows.filter((row) => row.status === "under_offer").length,
askingCents: open.reduce((sum, row) => sum + row.priceCents, 0),
medianCents: median(open.map((row) => row.priceCents)),
sold: rows.filter((row) => row.status === "sold").length,
rented: rows.filter((row) => row.status === "rented").length,
views: rows.reduce((sum, row) => sum + row.views, 0),
enquiries: rows.reduce((sum, row) => sum + row.enquiries, 0),
}
}
/** The line under the title: how many listings, and when they were read. */
export function lastUpdated(): string {
return `${db.listings.all().length} listings · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
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 }))
}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 Listing book page