Skip to contentVibraUI

Agency figures

The agents holding listings and the cities they cover, what is on the market, what closed this year — sold and let — and the enquiries every listing drew; reads agencyTotals(agents).

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-agents-agent-figures

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

Source

app/real-estate/agents/components/agent-figures.tsx
import { HandshakeIcon, MessageSquareIcon, UsersIcon, BuildingIcon } 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 AgencyTotals } from "../data"

/**
 * The agency in four numbers: the agents and the cities their books span,
 * what they have on the market, what they have closed this year, and the
 * enquiries their books have drawn. No state, so it renders on the server.
 */
export function AgentFigures({ totals, year }: { totals: AgencyTotals; year: number }) {
  return (
    <StatCardGroup data-widget="widget-real-estate-agents-agent-figures" columns={4} role="region" aria-label="The agency in figures">
      <StatCard
        label="Agents"
        value={formatNumber(totals.agents)}
        description={`Holding listings in ${totals.cities} cit${totals.cities === 1 ? "y" : "ies"}`}
        icon={<UsersIcon />}
      />
      <StatCard label="On the market" value={formatNumber(totals.active)} description="Listed or under offer" icon={<BuildingIcon />} />
      <StatCard
        label={`Closed in ${year}`}
        value={formatNumber(totals.sold + totals.rented)}
        description={`${formatNumber(totals.sold)} sold · ${formatNumber(totals.rented)} let`}
        icon={<HandshakeIcon />}
      />
      <StatCard
        label="Enquiries"
        value={formatNumber(totals.enquiries)}
        description="Drawn by every listing held"
        icon={<MessageSquareIcon />}
      />
    </StatCardGroup>
  )
}
app/real-estate/agents/data.ts
/**
 * What /real-estate/agents reads. An agent is a `db.members` row holding a
 * listing; their book is every `db.listings` row that names them. The summary
 * — the city, the number to call, the listings on the market, this year's
 * sales and lettings, every enquiry the book has drawn — and the interest by
 * week are the dashboard's own, in `@/lib/dashboards/real-estate/agents`,
 * which every listing's agent card reads too, so the two pages cannot
 * disagree. On top of them this page adds:
 *
 * - enquiries by week: an agent's week is the sum of their listings' weeks,
 *   the last twelve complete weeks;
 * - the next viewing: the soonest `nextViewingAt` on a listing of theirs
 *   still on the market.
 *
 * "Now" and "this year" are `REFERENCE_DATE`'s. The client islands import
 * only the types below.
 */
import { agentSummaries, mondayOf, weeklyInterest, type AgentSummary } from "@/lib/dashboards/real-estate/agents"
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 WEEK_MS = 7 * 86_400_000
const YEAR = REFERENCE_DATE.getUTCFullYear()

export type AgentRow = AgentSummary & {
  /** Their enquiries in each of the last twelve complete weeks, oldest first. */
  weekly: number[]
  /** Their soonest viewing booked on a listing still on the market. */
  nextViewing?: { at: Date; title: string; href: string }
}

/** The Mondays of the last twelve complete weeks, oldest first. */
function lastTwelveWeeks(): number[] {
  const monday = mondayOf(REFERENCE_DATE.getTime())
  return Array.from({ length: 12 }, (_, index) => monday - (12 - index) * WEEK_MS)
}

/** Everyone holding a listing, A to Z; the island sorts them the way the reader asks. */
export function agentRows(): AgentRow[] {
  const books = db.listings.all()
  const weeks = lastTwelveWeeks()
  return agentSummaries().map((agent) => {
    const book = books.filter((row) => row.agentId === agent.id)
    const byWeek = book.map((row) => new Map(weeklyInterest(row).map((week) => [week.start, week.enquiries])))
    const next = book
      .filter((row): row is Listing & { nextViewingAt: Date } => isOpen(row) && row.nextViewingAt instanceof Date)
      .sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime())[0]
    return {
      ...agent,
      weekly: weeks.map((start) => byWeek.reduce((sum, week) => sum + (week.get(start) ?? 0), 0)),
      ...(next ? { nextViewing: { at: next.nextViewingAt, title: next.title, href: listingHref(next.id) } } : {}),
    }
  })
}

export type AgencyTotals = {
  agents: number
  /** The cities the agents' books span — every city a listing of theirs is in, not only their home cities. */
  cities: number
  active: number
  sold: number
  rented: number
  enquiries: number
}

/** The four figures over every agent. */
export function agencyTotals(rows: AgentRow[]): AgencyTotals {
  const agents = new Set(rows.map((row) => row.id))
  return {
    agents: rows.length,
    cities: new Set(db.listings.all().filter((row) => agents.has(row.agentId)).map((row) => row.city)).size,
    active: rows.reduce((sum, row) => sum + row.active, 0),
    sold: rows.reduce((sum, row) => sum + row.soldThisYear, 0),
    rented: rows.reduce((sum, row) => sum + row.rentedThisYear, 0),
    enquiries: rows.reduce((sum, row) => sum + row.enquiries, 0),
  }
}

/** The line under the title: the year the closings are counted over, and when the book was read. */
export function lastUpdated(): string {
  return `${YEAR} to date · 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 Agents page