Skip to contentVibraUI

Interest by week

The listing's views and the enquiries they turned into, week by week over the last twelve complete weeks of its life; reads listingRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-real-estate-listing-interest-chart

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

Source

app/real-estate/listings/[id]/components/interest-chart.tsx
"use client"

import * as React from "react"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { SegmentedControl } from "@/components/ui/segmented-control"

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

const MEASURES = [
  { value: "views", label: "Views" },
  { value: "enquiries", label: "Enquiries" },
] as const

type Measure = (typeof MEASURES)[number]["value"]

/**
 * The listing's interest week by week, the last twelve complete weeks of its
 * life, one grey series at a time: views, or the enquiries they turned into.
 * A listing younger than a week has no complete week to show, and says so.
 */
export function InterestChart({ weeks, listed }: { weeks: WeekPoint[]; listed: string }) {
  const [measure, setMeasure] = React.useState<Measure>("views")
  const label = MEASURES.find((entry) => entry.value === measure)!.label
  const total = weeks.reduce((sum, week) => sum + week[measure], 0)

  return (
    <ChartCard
      data-widget="widget-real-estate-listing-interest-chart"
      title="Interest by week"
      description={
        weeks.length === 0
          ? `Listed ${listed}.`
          : `${formatNumber(total)} ${label.toLowerCase()} over the last ${weeks.length} complete week${weeks.length === 1 ? "" : "s"}.`
      }
      actions={
        <SegmentedControl
          size="sm"
          aria-label="Measure plotted"
          options={MEASURES.map((entry) => ({ value: entry.value, label: entry.label }))}
          value={measure}
          onValueChange={(value) => setMeasure(value as Measure)}
        />
      }
      height={220}
      empty={weeks.length === 0}
      emptyMessage="Its first week on the market is not over yet."
    >
      <BarChart
        data={weeks}
        index="week"
        series={[{ key: measure, label }]}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
        height={220}
        legend="none"
      />
    </ChartCard>
  )
}
app/real-estate/listings/[id]/data.ts
/**
 * What /real-estate/listings/[id] reads. The record is a `db.listings` row;
 * around it are the `db.members` row that holds it — the agent, with their
 * whole book — and the other listings in its city. A listing records one
 * asking price and its views and enquiries as totals, so the rest are rules
 * over the row:
 *
 * - price history: a listing that stays on the market is cut 2.5% every 45
 *   days, at most three times, so it was listed at today's asking price over
 *   0.975 per cut and reached it on schedule — each earlier price set the way
 *   an agent sets one, to the nearest $100 under $500,000 and the nearest
 *   $1,000 above, and each cut printed as the rule's 2.5%; a closing ends it
 *   at the asking price on `closedAt`;
 * - interest by week and the agent's summary are the dashboard's own rules,
 *   in `@/lib/dashboards/real-estate/agents` — the Agents page
 *   reads the same weeks and the same summary, so the two cannot disagree;
 * - the market: the asking price per m² against the median of the open
 *   listings in the same city — the page takes the median, from the others'
 *   prices here and this one's while it is on the market.
 *
 * "Now" is `REFERENCE_DATE`. The client islands import only the types below.
 */
import { agentSummary, mondayOf, weeklyInterest, type AgentSummary } from "@/lib/dashboards/real-estate/agents"
import { isOpen, listingHref } from "@/lib/dashboards/real-estate/vocabulary"
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"

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

/** How long a listing waits between price cuts, how deep each cut is, and how many it takes at most. */
const CUT_EVERY_DAYS = 45
const CUT = 0.025
const MAX_CUTS = 3

export type PriceEvent = {
  kind: "listed" | "reduced" | "sold" | "rented"
  at: Date
  priceCents: number
  /** The cut the rule made, as a ratio (−0.025); only a cut has one. */
  change?: number
}

export type WeekPoint = { week: string; views: number; enquiries: number }

export type Market = {
  perSqmCents: number
  /**
   * The asking price per m² of every other open listing in the city. The
   * page adds this one's while it is on the market and takes the median, so
   * a sale made on the page takes it out of its own comparison.
   */
  othersPerSqmCents: number[]
}

/** The listing and everything the page says about it, gathered in one read. */
export type ListingRecord = {
  listing: Listing
  href: string
  agent: AgentSummary | undefined
  history: PriceEvent[]
  /** Up to twelve complete weeks of the listing's life, oldest first. */
  weeks: WeekPoint[]
  market: Market
}

/**
 * The listing the page falls back to when it is rendered without a route
 * param — which is what the docs preview does: the open listing with the most
 * enquiries, the lowest id on a tie, so the preview has a price history, a
 * viewing and both actions to offer. Read per render, as the book stands: a
 * listing sold in the preview hands the fallback on to the next one.
 */
export function fallbackId(): string | undefined {
  return db.listings
    .all()
    .filter(isOpen)
    .sort((a, b) => b.enquiries - a.enquiries || a.id.localeCompare(b.id))[0]?.id
}

/** Every listing id, for `generateStaticParams`. */
export function listingIds(): string[] {
  return db.listings.all().map((row) => row.id)
}

/** An asking price as an agent sets one: to the nearest $100 under $500,000, the nearest $1,000 above. */
function asking(cents: number): number {
  const step = cents < 500_000_00 ? 100_00 : 1_000_00
  return Math.round(cents / step) * step
}

/** Whole days between listing and closing, or now. */
export function daysOnMarket(row: Listing): number {
  return Math.floor(((row.closedAt?.getTime() ?? NOW) - row.listedAt.getTime()) / DAY_MS)
}

/** The listing's asking prices, oldest first, and its closing. */
export function priceHistory(row: Listing): PriceEvent[] {
  const cuts = Math.min(MAX_CUTS, Math.floor(daysOnMarket(row) / CUT_EVERY_DAYS))
  const events: PriceEvent[] = []
  for (let step = 0; step <= cuts; step++) {
    const priceCents = step === cuts ? row.priceCents : asking(row.priceCents / (1 - CUT) ** (cuts - step))
    events.push({
      kind: step === 0 ? "listed" : "reduced",
      at: new Date(row.listedAt.getTime() + step * CUT_EVERY_DAYS * DAY_MS),
      priceCents,
      // The rule's cut, not the ratio of two rounded prices, which wanders
      // either side of it.
      ...(step > 0 ? { change: -CUT } : {}),
    })
  }
  if (row.closedAt && (row.status === "sold" || row.status === "rented")) {
    events.push({ kind: row.status, at: row.closedAt, priceCents: row.priceCents })
  }
  return events
}

const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** The last twelve complete weeks of the listing's life: to this week for an open one, to the week it closed for the rest. */
function chartWeeks(row: Listing): WeekPoint[] {
  const end = row.closedAt ? mondayOf(row.closedAt.getTime()) + WEEK_MS : mondayOf(NOW)
  return weeklyInterest(row)
    .filter((week) => week.start < end)
    .slice(-12)
    .map((week) => ({ week: WEEK_LABEL.format(new Date(week.start)), views: week.views, enquiries: week.enquiries }))
}

function marketOf(row: Listing): Market {
  const perSqm = (listing: Listing) => Math.round(listing.priceCents / listing.sqm)
  const others = db.listings.all().filter((other) => other.id !== row.id && other.city === row.city && isOpen(other))
  return { perSqmCents: perSqm(row), othersPerSqmCents: others.map(perSqm) }
}

/** The whole record, or undefined when the id names no listing. */
export async function listingRecord(id: string): Promise<ListingRecord | undefined> {
  const listing = await db.listings.get(id)
  if (!listing) return undefined
  return {
    listing,
    href: listingHref(listing.id),
    agent: agentSummary(listing.agentId),
    history: priceHistory(listing),
    weeks: chartWeeks(listing),
    market: marketOf(listing),
  }
}

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 Listing page page