Skip to contentVibraUI

Closed vs in progress

What has closed against the whole book as one band, and what is still being worked beside it; reads closedBand().

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-overview-closed-band

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

Source

app/real-estate/components/closed-band.tsx
import { formatNumber } from "@/lib/format"
import { ProgressStat } from "@/components/ui/progress-stat"
import { Widget } from "@/components/ui/widget"

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

export type ClosedBandProps = { band: ClosedBand }

/** What has closed against the whole book, as one band, with what is still in progress beside it. */
export function ClosedVsInProgress({ band }: ClosedBandProps) {
  return (
    <Widget data-widget="widget-real-estate-overview-closed-band" title="Closed vs in progress" description="The book, closed against still working" className="h-full">
      <div className="flex flex-col gap-3">
        <ProgressStat label="Closed" value={band.closed} target={band.total} tone="default" />
        <p className="text-xs text-muted-foreground">
          <span className="font-medium text-foreground tabular-nums">{`${formatNumber(band.inProgress)} in progress`}</span>
          {" · listed or under offer"}
        </p>
      </div>
    </Widget>
  )
}
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