Viewing agenda
The next four weeks day by day — the time, the listing, where it is and who is showing it — each viewing moved to another slot through the server; reads viewingRows().
Preview
"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} />
</>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-viewings-viewings-agendaNeeds the @vibra registry in your components.json — set it up once.
Source
"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} />
</>
)
}/**
* 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>
)
}/**
* 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 { 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
}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 Viewings agenda page