Agents
Everyone holding a listing, the city most of their book is in and a number to call; reads agents().
Preview
import { PhoneIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { type Agent } from "../data"
export type AgentsListProps = { agents: Agent[] }
/**
* Everyone holding a listing, the city most of their book is in, and a way to
* call them. The call is a plain `tel:` link styled as an icon button — a
* link, because that is what it is, and the phone app is where it goes.
*/
export function AgentsList({ agents }: AgentsListProps) {
return (
<Widget data-widget="widget-real-estate-overview-agents-list" title="Agents" description={`${formatNumber(agents.length)} with a book`} className="h-full">
<ul data-slot="agents" className="grid gap-x-6 sm:grid-cols-2">
{agents.map((agent) => (
<li key={agent.id} className="flex items-center gap-3 border-b py-2">
<UserCell
size="sm"
name={agent.name}
src={agent.avatarUrl}
description={`${agent.city} · ${formatNumber(agent.listings)} listing${agent.listings === 1 ? "" : "s"}`}
className="min-w-0 flex-1"
/>
<a
href={`tel:${agent.phone}`}
aria-label={`Call ${agent.name}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
>
<PhoneIcon aria-hidden="true" />
</a>
</li>
))}
</ul>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-overview-agents-listNeeds the @vibra registry in your components.json — set it up once.
Source
import { PhoneIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { type Agent } from "../data"
export type AgentsListProps = { agents: Agent[] }
/**
* Everyone holding a listing, the city most of their book is in, and a way to
* call them. The call is a plain `tel:` link styled as an icon button — a
* link, because that is what it is, and the phone app is where it goes.
*/
export function AgentsList({ agents }: AgentsListProps) {
return (
<Widget data-widget="widget-real-estate-overview-agents-list" title="Agents" description={`${formatNumber(agents.length)} with a book`} className="h-full">
<ul data-slot="agents" className="grid gap-x-6 sm:grid-cols-2">
{agents.map((agent) => (
<li key={agent.id} className="flex items-center gap-3 border-b py-2">
<UserCell
size="sm"
name={agent.name}
src={agent.avatarUrl}
description={`${agent.city} · ${formatNumber(agent.listings)} listing${agent.listings === 1 ? "" : "s"}`}
className="min-w-0 flex-1"
/>
<a
href={`tel:${agent.phone}`}
aria-label={`Call ${agent.name}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
>
<PhoneIcon aria-hidden="true" />
</a>
</li>
))}
</ul>
</Widget>
)
}/**
* What /real-estate reads. The book is `db.listings`; the agents are the
* `db.members` who hold one. A listing records its views and enquiries as
* totals, so anything the page reads by period is a rule over the row —
* interest accrues evenly while a listing is live, and a window gets the
* share of the listing's life that falls inside it; `performance.ts` holds
* that rule, the closings read off `closedAt`, and the seeded history the
* chart, the channel split and the spotlight draw on. Commission is three
* percent of what closed. "Now" is `REFERENCE_DATE`.
*
* The client islands import only the types below; `db` never crosses into the
* browser.
*/
import { agentSummaries } from "@/lib/dashboards/real-estate/agents"
import { isOpen, money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"
import { channelShares, enquiriesIn, isClosed, spotlightDailyViews, starOf } from "./performance"
export { performance, type PerformanceBucket, type PerformancePoint } from "./performance"
const NOW = REFERENCE_DATE.getTime()
/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
export const TODAY = utcDay(REFERENCE_DATE)
/** The agency's cut of a closing. */
const COMMISSION = 0.03
const listings = () => db.listings.all()
/** The change from `before` to `now` as a ratio; flat when there was nothing before. */
const change = (now: number, before: number): number => (before === 0 ? 0 : (now - before) / before)
/** Midnight UTC on the first of the month `offset` months from the current one. */
const monthStart = (offset: number): number =>
Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)
/** Midnight UTC on the first day of the quarter `offset` quarters from the current one. */
const quarterStart = (offset: number): number =>
Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + offset * 3, 1)
/** "Q3 2026" — the quarter the closings are counted over. */
export const QUARTER_LABEL = `Q${Math.floor(REFERENCE_DATE.getUTCMonth() / 3) + 1} ${REFERENCE_DATE.getUTCFullYear()}`
/* -------------------------------------------------------------------------- */
/* Headlines */
/* -------------------------------------------------------------------------- */
export type ListingStat = {
key: "active" | "enquiries" | "closed" | "revenue"
label: string
value: string
/** Change against the period before, as a ratio. */
delta?: number
description: string
}
/** The four headline numbers. */
export function listingStats(): ListingStat[] {
const rows = listings()
const active = rows.filter(isOpen)
const closedThisQuarter = rows.filter((row) => isClosed(row) && row.closedAt.getTime() >= quarterStart(0))
// Month to date against the same days of last month: four days against a
// whole month would read as a collapse.
const enquiries = enquiriesIn(monthStart(0), NOW)
const enquiriesBefore = enquiriesIn(monthStart(-1), monthStart(-1) + (NOW - monthStart(0)))
const commission = Math.round(closedThisQuarter.reduce((sum, row) => sum + row.priceCents, 0) * COMMISSION)
return [
{
key: "active",
label: "Active listings",
value: String(active.length),
description: `${active.filter((row) => row.status === "under_offer").length} under offer`,
},
{
key: "enquiries",
label: "Enquiries",
value: String(enquiries),
delta: change(enquiries, enquiriesBefore),
description: "vs the same days last month",
},
{
key: "closed",
label: "Closed this quarter",
value: String(closedThisQuarter.length),
description: `${closedThisQuarter.filter((row) => row.status === "sold").length} sold · ${closedThisQuarter.filter((row) => row.status === "rented").length} rented`,
},
{
key: "revenue",
label: "Revenue",
value: money(commission),
description: "3% commission on the quarter's closings",
},
]
}
/* -------------------------------------------------------------------------- */
/* Spotlight, the band, viewings, agents, channels, portfolio */
/* -------------------------------------------------------------------------- */
export type Spotlight = {
id: string
title: string
kind: Listing["kind"]
city: string
country: string
beds: number
baths: number
sqm: number
priceCents: number
status: Listing["status"]
enquiries: number
agent: string
/** The agent's whole book: what they have sold and let, and how many views it drew. */
book: { sold: number; rented: number; views: number }
/** Views on each of the last thirty days, oldest first. */
dailyViews: number[]
}
/** The most-enquired listing, its facts, and its agent's book. */
export function spotlight(): Spotlight {
const rows = listings()
const star = starOf(rows)
const agent = db.members.all().find((member) => member.id === star.agentId)
const book = rows.filter((row) => row.agentId === star.agentId)
return {
id: star.id,
title: star.title,
kind: star.kind,
city: star.city,
country: star.country,
beds: star.beds,
baths: star.baths,
sqm: star.sqm,
priceCents: star.priceCents,
status: star.status,
enquiries: star.enquiries,
agent: agent?.name ?? "Unassigned",
book: {
sold: book.filter((row) => row.status === "sold").length,
rented: book.filter((row) => row.status === "rented").length,
views: book.reduce((sum, row) => sum + row.views, 0),
},
dailyViews: spotlightDailyViews(),
}
}
export type ClosedBand = { closed: number; inProgress: number; total: number }
/** What has closed against what is still being worked. */
export function closedBand(): ClosedBand {
const rows = listings()
const closed = rows.filter(isClosed).length
return { closed, inProgress: rows.length - closed, total: rows.length }
}
export type Viewing = { id: string; title: string; city: string; kind: Listing["kind"]; agent: string; agentAvatar?: string; at: Date }
/** Every booked viewing, soonest first. */
export function viewings(): Viewing[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return listings()
.filter((row): row is Listing & { nextViewingAt: Date } => Boolean(row.nextViewingAt))
.sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime())
.map((row) => ({
id: row.id,
title: row.title,
city: row.city,
kind: row.kind,
agent: members.get(row.agentId)?.name ?? "Unassigned",
agentAvatar: members.get(row.agentId)?.avatarUrl,
at: row.nextViewingAt,
}))
}
export type Agent = { id: string; name: string; avatarUrl?: string; city: string; listings: number; phone: string }
/**
* Everyone holding a listing, with the city most of their book is in and a
* number to call — the dashboard's one agent summary
* (`@/lib/dashboards/real-estate/agents`), so the number and
* the city here are the ones the Agents page and every listing's agent card
* print.
*/
export function agents(): Agent[] {
return agentSummaries().map(({ id, name, avatarUrl, city, listings, phone }) => ({ id, name, avatarUrl, city, listings, phone }))
}
export type ChannelShare = { channel: string; closings: number }
/** The quarter's closings split by channel, from the seeded shares, summing to the count. */
export function salesByChannel(): ChannelShare[] {
const total = listings().filter((row) => isClosed(row) && row.closedAt.getTime() >= quarterStart(0)).length
const shares = channelShares()
let assigned = 0
return shares.map(({ channel, share }, index) => {
const closings = index === shares.length - 1 ? total - assigned : Math.round(total * share)
assigned += closings
return { channel, closings }
})
}
export type PortfolioSlice = { status: Listing["status"]; count: number; share: number }
const STATUSES: Listing["status"][] = ["listed", "under_offer", "sold", "rented"]
/** The book by status, each with its share of the whole. */
export function portfolio(): PortfolioSlice[] {
const rows = listings()
return STATUSES.map((status) => {
const count = rows.filter((row) => row.status === status).length
return { status, count, share: rows.length === 0 ? 0 : Math.round((count / rows.length) * 100) }
})
}
/** One row of the active listings table. */
export type ListingRow = {
id: string
title: string
kind: Listing["kind"]
city: string
priceCents: number
status: Listing["status"]
agent: string
listedAt: Date
}
/** Every listing still on the market; the table sorts and pages them itself. */
export function activeRows(): ListingRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return listings()
.filter(isOpen)
.map((row) => ({
id: row.id,
title: row.title,
kind: row.kind,
city: row.city,
priceCents: row.priceCents,
status: row.status,
agent: members.get(row.agentId)?.name ?? "Unassigned",
listedAt: row.listedAt,
}))
}
/** The line under the title: the quarter, and when the numbers were read. */
export function lastUpdated(): string {
return `${QUARTER_LABEL} to date · 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 owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}/**
* The rules with no rows of their own, and the history they build. A listing
* records its views and enquiries as totals, so a window gets the share of
* the listing's life that falls inside it; closings are read off `closedAt`.
* The months before the book's first listing, the channel split and the
* spotlight's daily views come once from `seeded("dashboard-real-estate")`.
* Server-only: this module reads `db`, and only `data.ts` and the page import
* it — the islands take the computed series as props.
*/
import { REFERENCE_DATE, db, seeded, type Listing } from "@/lib/sample-data"
const DAY = 86_400_000
const WEEK = 7 * DAY
const NOW = REFERENCE_DATE.getTime()
/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/** Monday of the week today falls in. */
const MONDAY = utcDay(REFERENCE_DATE) - ((new Date(utcDay(REFERENCE_DATE)).getUTCDay() + 6) % 7) * DAY
const listings = () => db.listings.all()
/** Sold or let, and carrying the day it closed — the guard the closings are read through. */
export const isClosed = (row: Listing): row is Listing & { closedAt: Date } =>
(row.status === "sold" || row.status === "rented") && row.closedAt instanceof Date
const between = (at: number, from: number, to: number): boolean => at >= from && at < to
/** Midnight UTC on the first of the month `offset` months from the current one. */
const monthStart = (offset: number): number =>
Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)
const yearStart = (offset: number): number => Date.UTC(REFERENCE_DATE.getUTCFullYear() + offset, 0, 1)
/**
* The share of a listing's total interest that fell inside a window: its
* life runs from the day it was listed to the day it closed, or to now, and
* interest is taken to accrue evenly across it.
*/
function accrued(row: Listing, key: "views" | "enquiries", from: number, to: number): number {
const start = row.listedAt.getTime()
const end = row.closedAt?.getTime() ?? NOW
const live = Math.max(1, (end - start) / DAY)
const overlap = Math.max(0, Math.min(end, to) - Math.max(start, from)) / DAY
return (row[key] * overlap) / live
}
export const enquiriesIn = (from: number, to: number): number =>
Math.round(listings().reduce((sum, row) => sum + accrued(row, "enquiries", from, to), 0))
const viewingsIn = (from: number, to: number): number =>
Math.round(listings().reduce((sum, row) => sum + accrued(row, "views", from, to), 0))
/** The asking prices, in cents, of what closed inside a window. */
const closingsIn = (from: number, to: number): number =>
listings()
.filter((row) => isClosed(row) && between(row.closedAt.getTime(), from, to))
.reduce((sum, row) => sum + row.priceCents, 0)
/* -------------------------------------------------------------------------- */
/* The seeded pieces */
/* -------------------------------------------------------------------------- */
type Bucket = { start: number; closingsCents: number; viewings: number }
// Not exported: an island would carry `db` into the browser by importing it;
// each row names its channel for the island to print.
const CHANNELS = ["Direct", "Portal", "Referral", "Network"] as const
type Seeded = {
/** The last twelve complete calendar months, oldest first. */
months: Bucket[]
/** Three calendar years, oldest first; the current one to date. */
years: (Bucket & { label: string })[]
/** Each channel's share of the closings, summing to one. */
channelShares: number[]
/** The spotlight listing's views on each of the last thirty days. */
spotlightViews: number[]
}
/** The listing the spotlight falls on: the most enquired, and the lowest id on a tie. */
export function starOf(rows: Listing[]): Listing {
return [...rows].sort((a, b) => b.enquiries - a.enquiries || a.id.localeCompare(b.id))[0]
}
/** How many listings live at a month's start count as a mature book: half of it. */
const MATURE_LIVE = 30
/** How many listings were on the market when `at` came. */
const liveAt = (rows: Listing[], at: number): number =>
rows.filter((row) => row.listedAt.getTime() < at && (row.closedAt?.getTime() ?? NOW) >= at).length
/**
* Everything on the page with no rows behind it, from one generator. The
* book was empty a year ago and filled through the spring, so a month's
* value is blended between what the book records and the pace the mature
* months set — drawn a third either way — by the share of a mature book
* that was live when the month began: an empty month is all drawn, a full
* one all measured, and the join between them is a slope rather than a
* cliff. A year the twelve months do not cover is drawn at that pace times
* twelve; the current year is the sum of its months so far. The channel
* shares fall off from the first channel, and the spotlight's daily views
* scatter around its own daily rate.
*/
function generate(): Seeded {
const rand = seeded("dashboard-real-estate")
const rows = listings()
// The last twelve complete months: the current one is four days old and
// would read as a cliff at the end of every series.
const starts = Array.from({ length: 12 }, (_, index) => monthStart(index - 12))
const measured = starts.map((start, index) => {
const end = monthStart(index - 11)
return {
start,
weight: Math.min(1, liveAt(rows, start) / MATURE_LIVE),
closingsCents: closingsIn(start, end),
viewings: viewingsIn(start, end),
}
})
const mature = measured.filter((month) => month.weight === 1)
const pace = {
closingsCents: mature.reduce((sum, month) => sum + month.closingsCents, 0) / Math.max(1, mature.length),
viewings: mature.reduce((sum, month) => sum + month.viewings, 0) / Math.max(1, mature.length),
}
const drawn = (value: number) => Math.round(value * (0.65 + rand() * 0.7))
const blend = (weight: number, measuredValue: number, drawnValue: number) =>
Math.round(weight * measuredValue + (1 - weight) * drawnValue)
const months = measured.map((month) => ({
start: month.start,
closingsCents: blend(month.weight, month.closingsCents, drawn(pace.closingsCents)),
viewings: blend(month.weight, month.viewings, drawn(pace.viewings)),
}))
const years = [-2, -1, 0].map((offset) => {
const from = yearStart(offset)
const to = yearStart(offset + 1)
const inYear = months.filter((month) => between(month.start, from, to))
// Covered when every complete month of the year is in the twelve.
const complete = offset === 0 ? REFERENCE_DATE.getUTCMonth() : 12
const label = `${new Date(from).getUTCFullYear()}${offset === 0 ? " YTD" : ""}`
if (inYear.length > 0 && inYear.length === complete) {
return {
start: from,
label,
closingsCents: inYear.reduce((sum, month) => sum + month.closingsCents, 0),
viewings: inYear.reduce((sum, month) => sum + month.viewings, 0),
}
}
return { start: from, label, closingsCents: drawn(pace.closingsCents * 12), viewings: drawn(pace.viewings * 12) }
})
// Four shares of one, each about half the one before, plus a little noise.
const raw = CHANNELS.map((_, index) => 0.55 ** index * (0.9 + rand() * 0.2))
const total = raw.reduce((sum, value) => sum + value, 0)
const channelShares = raw.map((value) => value / total)
const star = starOf(rows)
const life = Math.max(1, ((star.closedAt?.getTime() ?? NOW) - star.listedAt.getTime()) / DAY)
const daily = star.views / life
const spotlightViews = Array.from({ length: 30 }, () => Math.max(0, Math.round(daily * (0.6 + rand() * 0.8))))
return { months, years, channelShares, spotlightViews }
}
// Drawn per call: the draws are a fixed number in a fixed order whatever the
// book holds, so they read the same every time, while the months, the star and
// its views come off the listings as they stand — a closing recorded since the
// server started is in the next render.
/** Each channel's share of the closings, in the channels' order. */
export function channelShares(): { channel: string; share: number }[] {
const shares = generate().channelShares
return CHANNELS.map((channel, index) => ({ channel, share: shares[index] }))
}
/** The spotlight listing's views on each of the last thirty days, oldest first. */
export function spotlightDailyViews(): number[] {
return generate().spotlightViews
}
/* -------------------------------------------------------------------------- */
/* Performance */
/* -------------------------------------------------------------------------- */
export type PerformanceBucket = "W" | "M" | "Y"
/** One plotted point: closings in thousands of dollars, and viewings. */
export type PerformancePoint = { label: string; closings: number; viewings: number }
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const MONTH_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const thousands = (cents: number): number => Math.round(cents / 100_000)
/**
* Closings against viewings at the three bucket sizes the control offers:
* the last twelve complete weeks off the book, and the twelve months and
* three years of the seeded history.
*/
export function performance(): Record<PerformanceBucket, PerformancePoint[]> {
const weeks = Array.from({ length: 12 }, (_, index) => {
const start = MONDAY - (12 - index) * WEEK
const end = start + WEEK
return { label: WEEK_LABEL.format(new Date(start)), closings: thousands(closingsIn(start, end)), viewings: viewingsIn(start, end) }
})
const drawn = generate()
const months = drawn.months.map((month) => ({
label: MONTH_LABEL.format(new Date(month.start)),
closings: thousands(month.closingsCents),
viewings: month.viewings,
}))
const years = drawn.years.map((year) => ({
label: year.label,
closings: thousands(year.closingsCents),
viewings: year.viewings,
}))
return { W: weeks, M: months, Y: years }
}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 Real estate dashboard page