Viewing days
The four weeks as a month, every day with a viewing marked with how many, and how many the picked day holds; reads viewingRows().
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-viewings-viewings-calendarNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }))
}/**
* 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