Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-real-estate-viewings-viewings-calendar

Needs the @vibra registry in your components.json — set it up once.

Source

app/real-estate/viewings/components/viewings-calendar.tsx
"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>
  )
}
app/real-estate/viewings/data.ts
/**
 * 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 }))
}
app/real-estate/viewings/components/viewing-vocabulary.ts
/**
 * 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`)) }
})
app/real-estate/viewings/components/viewings-state.tsx
"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