Skip to contentVibraUI

Listings headline

Listings on the market and how many are under offer, the month's enquiries against the same days of the last, the quarter's closings and the commission they earned; reads listingStats().

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-overview-listing-stats

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

Source

app/real-estate/components/listing-stats.tsx
import { BuildingIcon, CheckCircle2Icon, MessageSquareIcon, ReceiptIcon } from "lucide-react"

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

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

// One icon per headline, keyed by the stat rather than its position, so the
// row can be reordered without the icons following the wrong numbers.
const ICONS: Record<ListingStat["key"], React.ReactNode> = {
  active: <BuildingIcon />,
  enquiries: <MessageSquareIcon />,
  closed: <CheckCircle2Icon />,
  revenue: <ReceiptIcon />,
}

export type ListingStatsProps = { stats: ListingStat[] }

/** The four headline numbers. No state, so the tiles render on the server. */
export function ListingStats({ stats }: ListingStatsProps) {
  return (
    <StatCardGroup data-widget="widget-real-estate-overview-listing-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          id={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/real-estate/data.ts
/**
 * 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 }
}
app/real-estate/performance.ts
/**
 * 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