Price history
The asking price from the day it was listed, each cut with its size, and the closing once there is one; reads listingRecord(id).
Preview
"use client"
import * as React from "react"
import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDelta } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
import { type ListingChange } from "../actions"
import { type PriceEvent } from "../data"
import { useListingState } from "./listing-state"
import { DAY } from "./listing-vocabulary"
const TITLES: Record<PriceEvent["kind"], string> = {
listed: "Listed at",
reduced: "Reduced to",
sold: "Sold at",
rented: "Let at",
}
const TONES: Record<PriceEvent["kind"], "default" | "success" | "info"> = {
listed: "info",
reduced: "default",
sold: "success",
rented: "success",
}
/** The history as the server read it, ended by a closing made on this page since, at the price it was asking. */
function withClosing(events: PriceEvent[], listing: ListingChange): PriceEvent[] {
if (listing.status !== "sold" && listing.status !== "rented") return events
if (!listing.closedAt || events.some((event) => event.kind === "sold" || event.kind === "rented")) return events
const asking = events[events.length - 1]
return asking ? [...events, { kind: listing.status, at: listing.closedAt, priceCents: asking.priceCents }] : events
}
/**
* The asking price from the day it was listed: each cut with the rule's size,
* and the closing — a sale made on this page included, since it reads the
* page's listing state.
*/
export function PriceHistory({ events: recorded }: { events: PriceEvent[] }) {
const { listing } = useListingState()
const events = React.useMemo(() => withClosing(recorded, listing), [recorded, listing])
const cuts = events.filter((event) => event.kind === "reduced").length
return (
<Card data-widget="widget-real-estate-listing-price-history" role="region" aria-labelledby="listing-history-title">
<CardHeader>
<CardTitle id="listing-history-title">Price history</CardTitle>
<CardDescription>
{cuts === 0 ? "Still at the price it was listed at." : `${cuts} price cut${cuts === 1 ? "" : "s"} since it was listed.`}
</CardDescription>
</CardHeader>
<CardContent>
<Timeline aria-label="Asking price, oldest first">
{events.map((event) => (
<TimelineItem
key={`${event.kind}-${event.at.getTime()}`}
tone={TONES[event.kind]}
title={`${TITLES[event.kind]} ${money(event.priceCents)}`}
description={event.change === undefined ? undefined : `${formatDelta(event.change, { maximumFractionDigits: 1 })} on the price before`}
time={DAY.format(event.at)}
/>
))}
</Timeline>
</CardContent>
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-listing-price-historyNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDelta } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
import { type ListingChange } from "../actions"
import { type PriceEvent } from "../data"
import { useListingState } from "./listing-state"
import { DAY } from "./listing-vocabulary"
const TITLES: Record<PriceEvent["kind"], string> = {
listed: "Listed at",
reduced: "Reduced to",
sold: "Sold at",
rented: "Let at",
}
const TONES: Record<PriceEvent["kind"], "default" | "success" | "info"> = {
listed: "info",
reduced: "default",
sold: "success",
rented: "success",
}
/** The history as the server read it, ended by a closing made on this page since, at the price it was asking. */
function withClosing(events: PriceEvent[], listing: ListingChange): PriceEvent[] {
if (listing.status !== "sold" && listing.status !== "rented") return events
if (!listing.closedAt || events.some((event) => event.kind === "sold" || event.kind === "rented")) return events
const asking = events[events.length - 1]
return asking ? [...events, { kind: listing.status, at: listing.closedAt, priceCents: asking.priceCents }] : events
}
/**
* The asking price from the day it was listed: each cut with the rule's size,
* and the closing — a sale made on this page included, since it reads the
* page's listing state.
*/
export function PriceHistory({ events: recorded }: { events: PriceEvent[] }) {
const { listing } = useListingState()
const events = React.useMemo(() => withClosing(recorded, listing), [recorded, listing])
const cuts = events.filter((event) => event.kind === "reduced").length
return (
<Card data-widget="widget-real-estate-listing-price-history" role="region" aria-labelledby="listing-history-title">
<CardHeader>
<CardTitle id="listing-history-title">Price history</CardTitle>
<CardDescription>
{cuts === 0 ? "Still at the price it was listed at." : `${cuts} price cut${cuts === 1 ? "" : "s"} since it was listed.`}
</CardDescription>
</CardHeader>
<CardContent>
<Timeline aria-label="Asking price, oldest first">
{events.map((event) => (
<TimelineItem
key={`${event.kind}-${event.at.getTime()}`}
tone={TONES[event.kind]}
title={`${TITLES[event.kind]} ${money(event.priceCents)}`}
description={event.change === undefined ? undefined : `${formatDelta(event.change, { maximumFractionDigits: 1 })} on the price before`}
time={DAY.format(event.at)}
/>
))}
</Timeline>
</CardContent>
</Card>
)
}/**
* 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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { formatDate } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Result } from "@/lib/sample-data"
/**
* The three things this page changes. Each action looks the listing up by id
* and trusts only the stored row; a listing that has sold or let is off the
* market, and both verbs refuse it with the day it closed. What comes back is
* the listing trimmed to what the page prints, never the stored row.
*/
export type ListingChange = { id: string; title: string; status: Listing["status"]; closedAt?: Date }
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** Takes an offer on a listing that is on the market; refuses one already under offer, sold or let. */
export async function markUnderOffer(id: string): Promise<Result<ListingChange>> {
const row = await db.listings.get(id)
if (!row) return { ok: false, error: { code: "not_found", message: `No listing with id "${id}".` } }
if (row.status === "sold" || row.status === "rented") {
return { ok: false, error: { code: "closed", message: offTheMarket(row) } }
}
if (row.status === "under_offer") {
return { ok: false, error: { code: "already_under_offer", message: `${row.title} is already under offer.` } }
}
const updated = await db.listings.update(row.id, { status: "under_offer" })
if (!updated.ok) return updated
return { ok: true, data: { id: row.id, title: row.title, status: updated.data.status } }
}
/** Closes a listing on the market as sold, today; refuses one already sold or let. */
export async function markSold(id: string): Promise<Result<ListingChange>> {
const row = await db.listings.get(id)
if (!row) return { ok: false, error: { code: "not_found", message: `No listing with id "${id}".` } }
if (row.status === "sold" || row.status === "rented") {
return { ok: false, error: { code: "closed", message: offTheMarket(row) } }
}
const updated = await db.listings.update(row.id, { status: "sold", closedAt: REFERENCE_DATE })
if (!updated.ok) return updated
return { ok: true, data: { id: row.id, title: row.title, status: updated.data.status, closedAt: REFERENCE_DATE } }
}
/** "14 Linden Lane was sold on Aug 2, 2026 — it is off the market." */
function offTheMarket(row: Listing): string {
const verb = row.status === "sold" ? "sold" : "let"
const day = row.closedAt ? ` on ${formatDate(row.closedAt, "medium", { timeZone: "UTC" })}` : ""
return `${row.title} was ${verb}${day} — it is off the market.`
}"use client"
import * as React from "react"
import { LISTING_STATUS_LABELS, LISTING_STATUS_MAP } from "@/lib/dashboards/real-estate/vocabulary"
import { StatusBadge } from "@/components/ui/status-badge"
import { type ListingChange } from "../actions"
import { DAY } from "./listing-vocabulary"
const DAY_MS = 86_400_000
type ListingState = {
/** Where the listing stands now, a sale made on this page included. */
listing: ListingChange
/** Where it stood when the server read it: what the server-side figures were counted with. */
initial: ListingChange
/** Takes what the server handed back after an action. */
settle: (next: ListingChange) => void
}
const ListingStateContext = React.createContext<ListingState | null>(null)
/**
* Where the listing stands, held once for the page. Everything that changes
* when it sells reads this state — the header's pill and the line under the
* title, the deal card's verbs, the city comparison, the price history, the
* agent's book and the viewing — so a sale shows in all of them the moment
* the server says so, with nothing re-read and no refresh.
*/
export function ListingStateProvider({ initial, children }: { initial: ListingChange; children: React.ReactNode }) {
const [listing, setListing] = React.useState(initial)
const value = React.useMemo(() => ({ listing, initial, settle: setListing }), [listing, initial])
return <ListingStateContext.Provider value={value}>{children}</ListingStateContext.Provider>
}
export function useListingState(): ListingState {
const state = React.useContext(ListingStateContext)
if (!state) throw new Error("useListingState needs a ListingStateProvider above it")
return state
}
/** The line under the title: when it went on the market and for how long — or, once it has closed, after how long. */
export function ListingMarketLine({ listedAt, now }: { listedAt: Date; now: Date }) {
const { listing } = useListingState()
const days = Math.floor(((listing.closedAt ?? now).getTime() - listedAt.getTime()) / DAY_MS)
const span = `${days} day${days === 1 ? "" : "s"}`
const listed = DAY.format(listedAt)
return listing.closedAt ? `Listed ${listed} · closed after ${span}` : `Listed ${listed} · ${span} on the market`
}
/** The status pill beside the page title. */
export function ListingStatusBadge() {
const { listing } = useListingState()
return (
<StatusBadge status={listing.status} label={LISTING_STATUS_LABELS[listing.status]} map={LISTING_STATUS_MAP} />
)
}/**
* How this page writes a day and a viewing's slot. The words the whole
* dashboard shares — a listing's kind and status, its glyph, how a price is
* written — are in `@/lib/dashboards/real-estate/vocabulary`.
*/
export const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** "Tue, Sep 8 · 10:30 AM", in UTC, the zone viewings are booked in. */
export const VIEWING_DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export const VIEWING_TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })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