/real-estate/viewingsViewings agenda
The next four weeks of viewings, day by day — the time, the listing, where it is and the agent showing it with a face — with a calendar that narrows the agenda to one day, four figures, and Reschedule through a server action that refuses a slot that has passed.
Installs at /real-estate/viewings. A viewing is a db.listings row's nextViewingAt on a listing still on the market — listed or under offer; one that has sold or let has nothing left to show. The page is a server component inside AppShell: viewingRows reads every booked viewing from REFERENCE_DATE to the end of the fourth week's last day — the four weeks are UTC days from today's midnight, the days the header names and the dialog offers — soonest first, each with the db.members row showing it, and hands them to one island. The agenda lays them out day by day in UTC, the zone viewings are booked in — a heading per day and, in time order, the time, the listing's glyph and title (a next/link to its page), its kind and city, an Under offer pill where one is taken, and the agent with a face. The MiniCalendar marks each day with its viewings and picks one to narrow the agenda to; the figures count the viewings booked, those in the next seven days, the busiest day and the agents showing, off the rows the island holds, so a move is in them at once. Reschedule opens a dialog with the four weeks' days and the quarter hours from nine to six, both selects described by the line saying the times are UTC, and every opening starts from the viewing's own slot with nothing refused: rescheduleViewing looks the listing up and trusts only the stored row, refuses a listing that has closed or has no viewing, a time that is not one and a time that has passed — in an alert inside the dialog, which stays open — and a move closes it, re-sorts the agenda and says where the viewing went in the status line above it. Focus follows the viewing: dismissing the dialog hands it back to the row's Reschedule button, and a move sends it to that viewing's button in its new place, the agenda following the viewing to its new day when one day is picked; a viewing that has left the agenda leaves focus on its day's heading or, failing that, the agenda's title, never the top of the page. Composes AppShell, PageHeader, StatCardGroup, StatCard, Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, MiniCalendar, Button, EmptyState, StatusBadge, UserCell, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, FormRow, NativeSelect, NativeSelectOption, AsyncButton and Callout.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { ViewingsBoard } from "./components/viewings-board"
import { WINDOW_DAYS, currentUser, lastUpdated, shellNotifications, today, viewingRows } from "./data"
/**
* The viewings. The page is a server component: it reads every listing still
* on the market with a viewing booked in the next four weeks, and hands the
* rows to the island that lays them out by day, picks a day, and moves one.
*/
export default function ViewingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.viewings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Viewings"
description="Who is being shown what over the next four weeks, day by day, and a way to move a viewing."
meta={lastUpdated()}
/>
<ViewingsBoard rows={viewingRows()} now={REFERENCE_DATE} today={today()} windowDays={WINDOW_DAYS} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/real-estate-viewingsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { ViewingsBoard } from "./components/viewings-board"
import { WINDOW_DAYS, currentUser, lastUpdated, shellNotifications, today, viewingRows } from "./data"
/**
* The viewings. The page is a server component: it reads every listing still
* on the market with a viewing booked in the next four weeks, and hands the
* rows to the island that lays them out by day, picks a day, and moves one.
*/
export default function ViewingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.viewings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Viewings"
description="Who is being shown what over the next four weeks, day by day, and a way to move a viewing."
meta={lastUpdated()}
/>
<ViewingsBoard rows={viewingRows()} now={REFERENCE_DATE} today={today()} windowDays={WINDOW_DAYS} />
</AppShell>
)
}/**
* What /real-estate/viewings reads. A viewing is a `db.listings` row's
* `nextViewingAt`, on a listing still on the market (listed or under offer):
* a listing that has sold or let has nothing left to show, whatever its row
* still carries. The agenda is the next four weeks from `REFERENCE_DATE`, day
* by day in UTC — the zone viewings are booked in — and each viewing names
* the `db.members` row showing it.
*
* The client islands import only the types below; the days and the slots
* the reschedule form offers live in `components/viewing-vocabulary.ts`, and
* the listing's words in the dashboard's vocabulary.
*/
import { isOpen, listingHref } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** How far ahead the agenda reads. */
export const WINDOW_DAYS = 28
/** One booked viewing, and everything the agenda prints about it. */
export type ViewingRow = {
/** The listing's id: a listing has one viewing booked at a time. */
id: string
title: string
kind: Listing["kind"]
city: string
status: Listing["status"]
agent: string
agentAvatar?: string
at: Date
/** The listing's own page. */
href: string
}
/**
* From now to the end of the agenda's last day. The four weeks are days in
* UTC from today's midnight — the days the agenda heads, the dialog offers
* and the line under the title names — so a viewing booked at ten on the day
* after them is out, although it is less than 28 × 24 hours from now.
*/
export function inWindow(at: Date): boolean {
const time = at.getTime()
return time >= REFERENCE_DATE.getTime() && time < today().getTime() + WINDOW_DAYS * DAY_MS
}
/** Every viewing booked in the next four weeks, soonest first. */
export function viewingRows(): ViewingRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.listings
.all()
.filter((row): row is Listing & { nextViewingAt: Date } => isOpen(row) && row.nextViewingAt instanceof Date)
.filter((row) => inWindow(row.nextViewingAt))
.sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime() || a.id.localeCompare(b.id))
.map((row) => ({
id: row.id,
title: row.title,
kind: row.kind,
city: row.city,
status: row.status,
agent: members.get(row.agentId)?.name ?? "Unassigned",
agentAvatar: members.get(row.agentId)?.avatarUrl,
at: row.nextViewingAt,
href: listingHref(row.id),
}))
}
/** Midnight UTC on the day of REFERENCE_DATE: the first day of the agenda, and the calendar's today. */
export function today(): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))
}
/** The line under the title: the window, and when the book was read. */
export function lastUpdated(): string {
const last = new Date(today().getTime() + (WINDOW_DAYS - 1) * DAY_MS)
const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })
return `${day(today())} – ${day(last)}`
}
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, instant, type Result } from "@/lib/sample-data"
/** How far ahead a viewing can be booked: a year. A date in 9999 is not a viewing. */
const BOOKABLE_MS = 365 * 86_400_000
/**
* The two things this page changes. The move looks the listing up by id and
* trusts only the stored row; the time is the one thing the caller chooses,
* and it has to be a real one that has not passed. What comes back is the
* viewing trimmed to what the agenda prints, never the stored row.
*/
export type ViewingMove = { id: string; title: string; at: Date }
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** Moves a listing's booked viewing to `at` (an ISO instant); refuses a time that has passed. */
export async function rescheduleViewing(id: string, at: string): Promise<Result<ViewingMove>> {
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: `${row.title} has ${row.status === "sold" ? "sold" : "let"} — there is nothing left to view.` },
}
}
if (!row.nextViewingAt) {
return { ok: false, error: { code: "no_viewing", message: `${row.title} has no viewing booked to move.` } }
}
// A real instant in a sane span: "9999-12-31" and an Invalid Date are not.
const when = instant(at)
if (!when) {
return { ok: false, error: { code: "invalid_time", message: "That is not a day and time a viewing can be booked for.", field: "at" } }
}
if (when.getTime() <= REFERENCE_DATE.getTime()) {
const now = `${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}, ${new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(REFERENCE_DATE)}`
return { ok: false, error: { code: "in_the_past", message: `That slot has passed — it is ${now} now.`, field: "at" } }
}
if (when.getTime() > REFERENCE_DATE.getTime() + BOOKABLE_MS) {
return { ok: false, error: { code: "too_far", message: "Book a viewing within the next year.", field: "at" } }
}
const updated = await db.listings.update(row.id, { nextViewingAt: when })
if (!updated.ok) return updated
return { ok: true, data: { id: row.id, title: row.title, at: when } }
}import * as React from "react"
import Link from "next/link"
import { CalendarClockIcon } from "lucide-react"
import {
KIND_ICONS,
KIND_LABELS,
LISTING_STATUS_LABELS,
LISTING_STATUS_MAP,
} from "@/lib/dashboards/real-estate/vocabulary"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type ViewingRow } from "../data"
import { DAY_HEADING, TIME, dayKey } from "./viewing-vocabulary"
export type AgendaListProps = {
rows: ViewingRow[]
/** What to say when there is nothing to list. */
empty: string
onReschedule: (row: ViewingRow) => void
/** Filled with each row's Reschedule button by viewing id, so focus can find a viewing after it moves. */
actions?: React.RefObject<Map<string, HTMLButtonElement>>
}
/**
* The viewings day by day, soonest first: each day a heading and its
* viewings in time order — the time, the listing's glyph and title, where it
* is, who is showing it, and a way to move it. An offer on the listing is on
* the row, since a viewing on a listing under offer is a second look.
*/
export function AgendaList({ rows, empty, onReschedule, actions }: AgendaListProps) {
if (rows.length === 0) {
return <EmptyState size="sm" icon={<CalendarClockIcon />} title={empty} />
}
const days: { key: string; rows: ViewingRow[] }[] = []
for (const row of rows) {
const key = dayKey(row.at)
const last = days[days.length - 1]
if (last?.key === key) last.rows.push(row)
else days.push({ key, rows: [row] })
}
return (
<div data-slot="agenda" className="flex flex-col gap-5">
{days.map((day) => (
<section key={day.key} aria-labelledby={`agenda-${day.key}`} data-day={day.key} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
{/* Focusable from code only: where focus lands when a moved viewing's row is not in view. */}
<h2 id={`agenda-${day.key}`} tabIndex={-1} className="type-label rounded-sm focus-ring">
{DAY_HEADING.format(day.rows[0].at)}
</h2>
<p className="text-xs tabular-nums text-muted-foreground">
{`${day.rows.length} viewing${day.rows.length === 1 ? "" : "s"}`}
</p>
</div>
<ul className="flex flex-col">
{day.rows.map((row) => {
const Icon = KIND_ICONS[row.kind]
return (
<li
key={row.id}
data-slot="agenda-viewing"
className="flex items-start gap-3 border-b py-2.5 last:border-b-0 sm:items-center"
>
<time dateTime={row.at.toISOString()} className="w-16 shrink-0 pt-0.5 text-sm font-medium tabular-nums sm:pt-0">
{TIME.format(row.at)}
</time>
<span
aria-hidden="true"
className="hidden size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-background sm:flex"
>
<Icon className="size-4" />
</span>
{/* On a phone the viewing is two lines under its time — the
listing, then who is showing it — rather than one row
squeezing the title to a few letters. */}
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2 sm:items-center">
<span className="flex min-w-0 flex-1 flex-col">
<Link href={row.href} className="truncate rounded-sm text-sm font-medium underline-offset-4 hover:underline focus-ring">
{row.title}
</Link>
<span className="truncate text-xs text-muted-foreground">{`${KIND_LABELS[row.kind]} · ${row.city}`}</span>
</span>
{row.status === "under_offer" ? (
<StatusBadge status={row.status} label={LISTING_STATUS_LABELS[row.status]} map={LISTING_STATUS_MAP} />
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<UserCell size="sm" name={row.agent} src={row.agentAvatar} className="min-w-0 sm:w-36" />
<Button
ref={(button: HTMLButtonElement | null) => {
if (!button || !actions) return
actions.current.set(row.id, button)
return () => {
if (actions.current.get(row.id) === button) actions.current.delete(row.id)
}
}}
size="sm"
variant="outline"
aria-label={`Reschedule the viewing at ${row.title}`}
onClick={() => onReschedule(row)}
>
Reschedule
</Button>
</div>
</div>
</li>
)
})}
</ul>
</section>
))}
</div>
)
}"use client"
import * as React from "react"
import { type Result } from "@/lib/sample-data"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FormRow } from "@/components/ui/form-section"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { rescheduleViewing, type ViewingMove } from "../actions"
import { type ViewingRow } from "../data"
import { TIMES, TIME, dayKey, slot } from "./viewing-vocabulary"
export type RescheduleDialogProps = {
/** The viewing being moved; the dialog is open while there is one. */
viewing: ViewingRow | null
/** The days the form offers, from today. */
days: { value: string; label: string }[]
onClose: () => void
onMoved: (move: ViewingMove) => void
/**
* Where focus goes as the dialog closes; `null` is the dialog's default, the
* button that opened it. The board points it at a moved viewing's new row.
*/
returnFocus?: () => HTMLElement | null
}
/** "10:30" from a viewing's instant, in UTC, as the time select keys it. */
const timeKey = (at: Date): string => at.toISOString().slice(11, 16)
/**
* Moves one viewing to another day and time: two selects over the four weeks
* and the quarter hours viewings are booked in, both described by the line
* that says the times are UTC. The server decides — a slot that has passed is
* refused in an alert inside the dialog, where the reader is looking, and the
* dialog stays open to pick again; a move closes it, and the page's status
* line says where the viewing went.
*/
export function RescheduleDialog({ viewing, days, onClose, onMoved, returnFocus }: RescheduleDialogProps) {
const [shown, setShown] = React.useState(viewing)
const [openedFor, setOpenedFor] = React.useState(viewing)
const [day, setDay] = React.useState(viewing ? dayKey(viewing.at) : (days[0]?.value ?? ""))
const [time, setTime] = React.useState(viewing ? timeKey(viewing.at) : "09:00")
const [refusal, setRefusal] = React.useState("")
const ids = React.useId()
const zoneId = `${ids}-zone`
// Every opening starts from the viewing's own slot with nothing refused —
// reopening the same viewing after a refusal and Escape included. `shown`
// outlives the close, so the content holds still while the dialog fades.
if (viewing !== openedFor) {
setOpenedFor(viewing)
if (viewing) {
setShown(viewing)
setDay(dayKey(viewing.at))
setTime(timeKey(viewing.at))
setRefusal("")
}
}
async function save() {
if (!shown) return
setRefusal("")
const result: Result<ViewingMove> = await rescheduleViewing(shown.id, `${day}T${time}:00.000Z`)
if (!result.ok) return setRefusal(result.error.message)
onMoved(result.data)
}
return (
<Dialog
open={viewing !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent className="sm:max-w-md" finalFocus={returnFocus ?? true}>
<DialogHeader>
<DialogTitle>Reschedule the viewing</DialogTitle>
<DialogDescription>
{shown ? `${shown.title}, ${shown.city} — now ${slot(shown.at)}, shown by ${shown.agent}.` : null}
</DialogDescription>
</DialogHeader>
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<FormRow label="Day" htmlFor={`${ids}-day`}>
<NativeSelect
id={`${ids}-day`}
aria-describedby={zoneId}
className="w-full"
value={day}
onChange={(event) => setDay(event.target.value)}
>
{days.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</FormRow>
<FormRow label="Time" htmlFor={`${ids}-time`}>
<NativeSelect
id={`${ids}-time`}
aria-describedby={zoneId}
className="w-full"
value={time}
onChange={(event) => setTime(event.target.value)}
>
{TIMES.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</FormRow>
</div>
<p id={zoneId} className="text-xs text-muted-foreground">{`Times are in UTC. Viewings run from ${TIMES[0].label} to ${TIME.format(new Date(`1970-01-01T${TIMES[TIMES.length - 1].value}:00.000Z`))}.`}</p>
<DialogFooter>
<DialogClose render={<Button variant="outline" size="sm" type="button">Cancel</Button>} />
<AsyncButton size="sm" onClick={save}>
Move the viewing
</AsyncButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}import { CalendarClockIcon, CalendarDaysIcon, UsersIcon, CalendarRangeIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type ViewingRow } from "../data"
import { DAY_SHORT, dayKey } from "./viewing-vocabulary"
const DAY_MS = 86_400_000
/**
* The next four weeks in four numbers, counted off the rows the agenda holds,
* so a move is in the figures as soon as it is on the agenda: the viewings
* booked, those in the next seven days, the busiest day, and the agents
* showing.
*/
export function ViewingFigures({ rows, now }: { rows: ViewingRow[]; now: Date }) {
const week = rows.filter((row) => row.at.getTime() < now.getTime() + 7 * DAY_MS).length
const byDay = new Map<string, ViewingRow[]>()
for (const row of rows) byDay.set(dayKey(row.at), [...(byDay.get(dayKey(row.at)) ?? []), row])
const busiest = [...byDay.values()].sort((a, b) => b.length - a.length || a[0].at.getTime() - b[0].at.getTime())[0]
const agents = new Set(rows.map((row) => row.agent)).size
return (
<StatCardGroup data-widget="widget-real-estate-viewings-viewing-figures" columns={4} role="region" aria-label="The four weeks in figures">
<StatCard label="Booked" value={formatNumber(rows.length)} description="In the next four weeks" icon={<CalendarRangeIcon />} />
<StatCard label="This week" value={formatNumber(week)} description="In the next seven days" icon={<CalendarClockIcon />} />
<StatCard
label="Busiest day"
value={busiest ? DAY_SHORT.format(busiest[0].at) : "—"}
description={busiest ? `${busiest.length} viewing${busiest.length === 1 ? "" : "s"}` : "Nothing booked"}
icon={<CalendarDaysIcon />}
/>
<StatCard label="Agents showing" value={formatNumber(agents)} description="With a viewing booked" icon={<UsersIcon />} />
</StatCardGroup>
)
}/**
* The days and slots this page prints; a listing's kind and status are the
* dashboard's words, in `@/lib/dashboards/real-estate/vocabulary`.
* Vocabulary, not data — it lives beside the islands rather than in
* `data.ts`, which reads `db` and must never reach the browser. Every date is
* read in UTC, the zone viewings are booked in, so a viewing at ten is at ten
* for every reader.
*/
const DAY_MS = 86_400_000
export const DAY_HEADING = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
export const DAY_SHORT = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
/** "Tue, Sep 8 · 10:30 AM". */
export const slot = (at: Date): string => `${DAY_SHORT.format(at)} · ${TIME.format(at)}`
/** The `yyyy-mm-dd` a day is keyed by — the calendar's own key. */
export const dayKey = (date: Date): string => date.toISOString().slice(0, 10)
/** The days the agenda covers, from `today`, as the reschedule form offers them. */
export function agendaDays(today: Date, count: number): { value: string; label: string }[] {
return Array.from({ length: count }, (_, index) => {
const day = new Date(today.getTime() + index * DAY_MS)
return { value: dayKey(day), label: DAY_SHORT.format(day) }
})
}
/** The quarter hours from nine to six, when viewings are booked. */
export const TIMES: { value: string; label: string }[] = Array.from({ length: 37 }, (_, index) => {
const minutes = 9 * 60 + index * 15
const value = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
return { value, label: TIME.format(new Date(`1970-01-01T${value}:00.000Z`)) }
})"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { type ViewingMove } from "../actions"
import { type ViewingRow } from "../data"
import { AgendaList } from "./agenda-list"
import { RescheduleDialog } from "./reschedule-dialog"
import { DAY_HEADING, agendaDays, dayKey, slot } from "./viewing-vocabulary"
import { useViewings } from "./viewings-state"
/**
* Scrolls `target` to the middle of the screen unless it is already there to
* be seen: on screen, and not under the shell's sticky header — whatever sits
* on top at its centre has to be the target itself.
*/
function reveal(target: HTMLElement) {
const rect = target.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const seen = y > 0 && y < window.innerHeight ? document.elementFromPoint?.(x, y) : null
if (!seen || !target.contains(seen)) target.scrollIntoView?.({ block: "center" })
}
/**
* The four weeks' viewings day by day — or the one day the calendar picked —
* each with a way to move it. A move the server accepts goes to the provider,
* which takes the viewing to its new day, and the status line under the title
* says where it went; the dialog is the agenda's own, so the agenda drawn
* alone still moves a viewing.
*
* Focus follows a move. Closing the dialog any other way hands focus back to
* the Reschedule button that opened it; a move sends it to that viewing's
* button in its new place — a move to another day mounts a new row there, so
* the button that opened the dialog is gone. A viewing that has left the
* agenda leaves its day's heading to land on, and failing that, the agenda's
* title: never the top of the document.
*/
export function ViewingsAgenda({ className }: { className?: string }) {
const { rows, today, windowDays, picked, pick, settle } = useViewings()
const [moving, setMoving] = React.useState<ViewingRow | null>(null)
const [notice, setNotice] = React.useState("")
// Each row's Reschedule button by viewing id, the agenda, and its title.
const actions = React.useRef(new Map<string, HTMLButtonElement>())
const agendaRef = React.useRef<HTMLDivElement>(null)
const titleRef = React.useRef<HTMLDivElement>(null)
// Where the last accepted move put the viewing; cleared as the next dialog opens.
const landing = React.useRef<{ id: string; day: string } | null>(null)
const days = React.useMemo(() => agendaDays(today, windowDays), [today, windowDays])
const shown = picked ? rows.filter((row) => dayKey(row.at) === dayKey(picked)) : rows
function moved(move: ViewingMove) {
const at = new Date(move.at)
landing.current = { id: move.id, day: dayKey(at) }
setMoving(null)
settle(move.id, at)
setNotice(`Moved the viewing at ${move.title} to ${slot(at)}.`)
}
/** What the closing dialog hands focus to. `null` is the dialog's own default: the button that opened it. */
function returnFocus(): HTMLElement | null {
const move = landing.current
if (!move) return null
const target =
actions.current.get(move.id) ??
agendaRef.current?.querySelector<HTMLElement>(`section[data-day="${move.day}"] h2`) ??
titleRef.current
// The dialog focuses without scrolling; a viewing moved three weeks on is
// far down the agenda, and focus should not land out of sight. Once the
// dialog has gone and focus has landed, bring it into view if it is not.
if (target) window.requestAnimationFrame?.(() => reveal(target))
return target
}
return (
<>
<Card
data-widget="widget-real-estate-viewings-viewings-agenda"
role="region"
aria-labelledby="viewings-agenda-title"
className={className}
>
<CardHeader>
<CardTitle id="viewings-agenda-title" ref={titleRef} tabIndex={-1} className="rounded-sm focus-ring">
Agenda
</CardTitle>
<CardDescription className="tabular-nums">
{picked
? `${DAY_HEADING.format(picked)} · ${shown.length} viewing${shown.length === 1 ? "" : "s"}`
: `The next four weeks · ${rows.length} viewing${rows.length === 1 ? "" : "s"}`}
</CardDescription>
{picked ? (
<CardAction>
<Button variant="ghost" size="sm" onClick={() => pick(undefined)}>
All four weeks
</Button>
</CardAction>
) : null}
</CardHeader>
<CardContent ref={agendaRef} className="flex flex-col gap-3">
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
<AgendaList
rows={shown}
empty={picked ? `Nothing booked on ${DAY_HEADING.format(picked)}.` : "Nothing booked in the next four weeks."}
actions={actions}
onReschedule={(row) => {
landing.current = null
setNotice("")
setMoving(row)
}}
/>
</CardContent>
</Card>
{/* The dialog draws into a portal, so the card stays the one element here. */}
<RescheduleDialog viewing={moving} days={days} onClose={() => setMoving(null)} onMoved={moved} returnFocus={returnFocus} />
</>
)
}"use client"
import { type ViewingRow } from "../data"
import { ViewingFigures } from "./viewing-figures"
import { ViewingsAgenda } from "./viewings-agenda"
import { ViewingsCalendar } from "./viewings-calendar"
import { ViewingsProvider, useViewings } from "./viewings-state"
export type ViewingsBoardProps = {
rows: ViewingRow[]
/** REFERENCE_DATE: "now", for this week and for what has passed. */
now: Date
/** Midnight UTC on now's day: the agenda's first day. */
today: Date
windowDays: number
}
/** The figures over the rows the provider holds, so a move is in them as soon as it is on the agenda. */
function Figures({ now }: { now: Date }) {
const { rows } = useViewings()
return <ViewingFigures rows={rows} now={now} />
}
/**
* The next four weeks of viewings: the figures, the calendar that picks a
* day and the agenda that reads it, over one set of rows the provider holds.
* Linked, since all three are drawn here: the calendar's pick narrows the
* agenda, and with no day picked all four weeks read in order.
*/
export function ViewingsBoard({ rows, now, today, windowDays }: ViewingsBoardProps) {
return (
<ViewingsProvider rows={rows} today={today} windowDays={windowDays} linked>
<div data-slot="viewings-board" className="flex flex-col gap-4">
<Figures now={now} />
<div className="grid gap-4 lg:grid-cols-3">
<ViewingsCalendar />
<ViewingsAgenda className="lg:col-span-2" />
</div>
</div>
</ViewingsProvider>
)
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { DAY_HEADING, dayKey } from "./viewing-vocabulary"
import { useViewings } from "./viewings-state"
const count = (viewings: number) => `${viewings} viewing${viewings === 1 ? "" : "s"}`
/**
* The four weeks as a month, every day with a viewing marked with how many.
* On the page it picks the day the agenda beside it reads; drawn alone, with
* no agenda to read it, it says under itself how many viewings the picked day
* holds.
*/
export function ViewingsCalendar({ className }: { className?: string }) {
const { rows, today, linked, picked, pick, month, setMonth } = useViewings()
const marks = React.useMemo(() => {
const out: Record<string, MiniCalendarMark> = {}
for (const row of rows) out[dayKey(row.at)] = { count: (out[dayKey(row.at)]?.count ?? 0) + 1, tone: "brand" }
return out
}, [rows])
const onDay = picked ? rows.filter((row) => dayKey(row.at) === dayKey(picked)).length : 0
return (
<Card
data-widget="widget-real-estate-viewings-viewings-calendar"
role="region"
aria-labelledby="viewings-picker-title"
className={cn("self-start", className)}
>
<CardHeader>
<CardTitle id="viewings-picker-title">Pick a day</CardTitle>
<CardDescription>
{linked ? "Days with viewings are marked; pick one to read it alone." : "Days with viewings are marked with how many."}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<MiniCalendar
month={month}
onMonthChange={setMonth}
value={picked}
onValueChange={pick}
marks={marks}
today={today}
countLabel={count}
className="w-full max-w-72"
/>
{linked ? null : (
<p data-slot="viewings-calendar-readout" className="w-full max-w-72 text-xs text-muted-foreground tabular-nums">
{picked ? `${DAY_HEADING.format(picked)} · ${count(onDay)}` : "Pick a day."}
</p>
)}
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { type ViewingRow } from "../data"
import { dayKey } from "./viewing-vocabulary"
const DAY_MS = 86_400_000
type Viewings = {
/** The four weeks' viewings, soonest first, every accepted move applied. */
rows: ViewingRow[]
/** Midnight UTC on now's day: the agenda's first day, and the calendar's today. */
today: Date
/** How many days the agenda reads from `today`. */
windowDays: number
/**
* The page draws the calendar and the agenda together and says so: the day
* the calendar picks is the day the agenda reads. A card drawn alone has no
* partner, so the calendar says what it picked itself.
*/
linked: boolean
/** The day picked in the calendar; with none, the agenda reads all four weeks. */
picked: Date | undefined
pick: (day: Date | undefined) => void
/** The month the calendar shows. */
month: Date
setMonth: (month: Date) => void
/**
* Takes a move the server accepted: the viewing goes to its new day,
* re-sorted — or leaves the four weeks — and a day being read follows it.
*/
settle: (id: string, at: Date) => void
}
const ViewingsContext = React.createContext<Viewings | null>(null)
/** Midnight UTC on the day `at` falls on: how the calendar holds a picked day. */
const dayOf = (at: Date): Date => new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()))
export type ViewingsProviderProps = {
rows: ViewingRow[]
today: Date
windowDays: number
/** The calendar and the agenda are both drawn; see `linked` above. */
linked?: boolean
children: React.ReactNode
}
/**
* The next four weeks of viewings, held once for the cards that read them.
* The rows arrive from the server page and the provider owns them from then
* on: a move the server accepts takes the viewing to its new day, re-sorted,
* and the figures, the calendar's marks and the agenda follow. The provider
* draws nothing of its own, so a card inside it alone is the whole tree.
*/
export function ViewingsProvider({ rows: initial, today, windowDays, linked = false, children }: ViewingsProviderProps) {
const [rows, setRows] = React.useState(initial)
const [picked, setPicked] = React.useState<Date | undefined>(undefined)
const [month, setMonth] = React.useState(today)
const end = today.getTime() + windowDays * DAY_MS
function settle(id: string, at: Date) {
setRows((current) =>
current
.map((row) => (row.id === id ? { ...row, at } : row))
// A viewing moved past the four weeks leaves the agenda.
.filter((row) => row.at.getTime() < end)
.sort((a, b) => a.at.getTime() - b.at.getTime() || a.id.localeCompare(b.id))
)
// Reading one day and moving a viewing off it: the agenda follows the
// viewing to its new day, so what was moved is in view, and in reach.
if (picked && dayKey(picked) !== dayKey(at) && at.getTime() < end) {
setPicked(dayOf(at))
setMonth(dayOf(at))
}
}
const value: Viewings = { rows, today, windowDays, linked, picked, pick: setPicked, month, setMonth, settle }
return <ViewingsContext.Provider value={value}>{children}</ViewingsContext.Provider>
}
/** The viewings the nearest `ViewingsProvider` holds. */
export function useViewings(): Viewings {
const viewings = React.useContext(ViewingsContext)
if (!viewings) throw new Error("useViewings needs a ViewingsProvider above it")
return viewings
}