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)
}