Skip to contentVibraUI
Dashboards

Real estate agents

The agents and the interest on their book, read one way for every Real estate page: each agent's city, number and book this year, and a listing's views and enquiries week by week.

Server-only, since it reads db: a page imports it from its data.ts and hands the results to its islands as props. The overview's agents, the Agents page's cards and a listing's agent card all come from agentSummaries, so none of them can disagree: the listings on the market, the sales and lettings closed this year by closedAt against REFERENCE_DATE's year, and every enquiry the whole book has drawn, open and closed listings alike. The city is where most of the book is; the number is a rule over the member's place in the directory, on the fictional 555-01xx exchange. weeklyInterest cuts a listing's life (listed to closed, or to now) into weeks from Monday, weights each by the days of the life inside it, scatters them by seeded("real-estate-listing/<id>") and shares the totals out in whole numbers that add up exactly: the listing page charts those weeks, and the Agents page sums them over each book.

Install

npx shadcn@latest add @vibra/agents-real-estate

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

Props

PropTypeDefaultDescription
agentSummaries() => AgentSummary[]—Everyone holding a listing, A to Z.
agentSummary(memberId: string) => AgentSummary | undefined—One agent's summary; undefined for a member who holds no listing.
AgentSummary{ id, name, email, avatarUrl?, phone, city, listings, active, soldThisYear, rentedThisYear, enquiries }—One agent and their book.
weeklyInterest(row: Listing) => WeekOfInterest[]—A listing's life in weeks from Monday, oldest first, adding up to the views and enquiries the row records.
mondayOf / apportion(at: number) => number / (total: number, weights: number[]) => number[]—Monday 00:00 UTC of an instant's week; a total shared across weights in whole numbers that add up to it.

Dependencies

Source

lib/dashboards/real-estate/agents.ts
import { isOpen } from "@/lib/dashboards/real-estate/vocabulary"
import { REFERENCE_DATE, db, seeded, type Listing } from "@/lib/sample-data"

/**
 * The agents and the interest on their book, read one way for every page of
 * the Real estate dashboard. An agent is a `db.members` row holding a
 * listing, and their book is every `db.listings` row naming them. Server-only:
 * this module reads `db`, so pages import it from `data.ts` and hand the
 * results to their islands as props.
 *
 * - **The summary** is counted straight off the book: the listings on the
 *   market, the sales and lettings that closed this year (by `closedAt`,
 *   against `REFERENCE_DATE`'s year), and every enquiry the whole book has
 *   drawn. The city is where most of the book is, the first A to Z on a tie;
 *   the number is a rule over the member's place in the directory, on the
 *   fictional 555-01xx exchange.
 * - **Interest by week**: a listing records its views and enquiries as
 *   totals, so its life — listed to closed, or to now — is cut into weeks
 *   from Monday, each week weighted by the days of the life inside it,
 *   scattered by `seeded("real-estate-listing/<id>")`, and the totals are
 *   shared out in whole numbers that add up to them exactly.
 */

const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
const NOW = REFERENCE_DATE.getTime()
const YEAR = REFERENCE_DATE.getUTCFullYear()

/** Monday 00:00 UTC of the week `at` falls in. */
export function mondayOf(at: number): number {
  const date = new Date(at)
  const day = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
  return day - ((new Date(day).getUTCDay() + 6) % 7) * DAY_MS
}

/** Shares `total` across `weights` in whole numbers that add up to it exactly. */
export function apportion(total: number, weights: number[]): number[] {
  const sum = weights.reduce((acc, weight) => acc + weight, 0)
  if (sum === 0) return weights.map(() => 0)
  let running = 0
  let given = 0
  return weights.map((weight) => {
    running += weight
    const upTo = Math.round((total * running) / sum)
    const share = upTo - given
    given = upTo
    return share
  })
}

/** One week of a listing's life: its Monday, and the views and enquiries it took. */
export type WeekOfInterest = { start: number; views: number; enquiries: number }

/** A listing's whole life in weeks, oldest first, adding up to the totals the row records. */
export function weeklyInterest(row: Listing): WeekOfInterest[] {
  const rand = seeded(`real-estate-listing/${row.id}`)
  const from = row.listedAt.getTime()
  const to = row.closedAt?.getTime() ?? NOW
  const starts: number[] = []
  for (let start = mondayOf(from); start < to; start += WEEK_MS) starts.push(start)
  const weights = starts.map((start) => {
    const days = Math.max(0, Math.min(to, start + WEEK_MS) - Math.max(from, start)) / DAY_MS
    return days * (0.6 + rand() * 0.8)
  })
  const views = apportion(row.views, weights)
  const enquiries = apportion(row.enquiries, weights)
  return starts.map((start, index) => ({ start, views: views[index], enquiries: enquiries[index] }))
}

/** One agent and their book. */
export type AgentSummary = {
  id: string
  name: string
  email: string
  avatarUrl?: string
  phone: string
  /** The city most of their book is in, the first A to Z on a tie. */
  city: string
  /** Every listing they hold, open or closed. */
  listings: number
  /** Listed or under offer. */
  active: number
  soldThisYear: number
  rentedThisYear: number
  /** Every enquiry their whole book has drawn. */
  enquiries: number
}

/** Everyone holding a listing, A to Z. */
export function agentSummaries(): AgentSummary[] {
  const books = db.listings.all()
  return db.members
    .all()
    .map((member, index) => ({ member, index, book: books.filter((row) => row.agentId === member.id) }))
    .filter(({ book }) => book.length > 0)
    .map(({ member, index, book }) => {
      const cities = new Map<string, number>()
      for (const row of book) cities.set(row.city, (cities.get(row.city) ?? 0) + 1)
      const closed = (status: Listing["status"]) =>
        book.filter((row) => row.status === status && row.closedAt?.getUTCFullYear() === YEAR).length
      return {
        id: member.id,
        name: member.name,
        email: member.email,
        avatarUrl: member.avatarUrl,
        phone: `+1 555 01${String(index + 1).padStart(2, "0")}`,
        city: [...cities.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0],
        listings: book.length,
        active: book.filter(isOpen).length,
        soldThisYear: closed("sold"),
        rentedThisYear: closed("rented"),
        enquiries: book.reduce((sum, row) => sum + row.enquiries, 0),
      }
    })
    .sort((a, b) => a.name.localeCompare(b.name))
}

/** One agent's summary, or undefined when the member holds no listing. */
export function agentSummary(memberId: string): AgentSummary | undefined {
  return agentSummaries().find((agent) => agent.id === memberId)
}