Skip to contentVibraUI

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

Install

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

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

Source

app/real-estate/viewings/components/viewings-agenda.tsx
"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} />
    </>
  )
}
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/actions.ts
"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 } }
}
app/real-estate/viewings/components/agenda-list.tsx
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>
  )
}
app/real-estate/viewings/components/reschedule-dialog.tsx
"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>
  )
}
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