Skip to contentVibraUI
Part of the People dashboardinstalls at /people/leave

Leave

Every leave request with the employee's face, its kind, dates and days, facets on status and kind, Approve and Decline on each pending row, and the month as a strip of days counting who is out on each — pick a day to see who.

Open the live page

Every row is a db.leaves request with its employee resolved against db.employees, so each carries an avatarFor face, a role and a department; the table opens on what is waiting, soonest first. Leave names days, not instants: from and to are midnight UTC and both are days away, so a Monday-to-Friday request is five days. The month strip is REFERENCE_DATE's month, one day a cell, each counting who is out on approved leave and marking the requests still waiting; it is one radio group with one tab stop and the arrow keys, weekends on the muted plane, today's number in ink, the picked day in the accent's tint, and it folds into Monday-first weeks on a phone. Under it, the picked day lists who is out and who has asked. The figures, the strip and the table share one client island, so a decision moves all three at once; the month's days off count working days only, since a weekend inside a fortnight's leave costs no work. decideLeave is the dashboard's shared server action from actions-people — the same one the people overview's queue calls — returning Result: only a pending request can be decided, and one already decided is refused with the decision it got; a refusal lands in a danger callout above the table and a success in an always-mounted status line. Composes AppShell, PageHeader, StatCardGroup, StatCard, Widget, UserCell, Badge, DataTable, DataTableColumnHeader, StatusBadge, NumberCell, TruncateCell, Button, Callout, MonthStrip (block-local) and LeaveView (block-local).

Preview

Install

npx shadcn@latest add @vibra/people-leave

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

Source

app/people/leave/page.tsx
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 { LeaveView } from "./components/leave-view"
import { currentUser, lastUpdated, leaveRows, shellNotifications, today } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * Every leave request. A server component: it reads `db.leaves` with each
 * employee resolved, and hands the rows and today's date to the one island
 * that counts them, draws the month and decides them.
 */
export default async function LeavePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.leave}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        titleId="people-leave-title"
        title="Leave"
        description="Who asked for time off, who is out this month, and what is still waiting on you."
        meta={lastUpdated()}
      />

      <LeaveView titleId="people-leave-title" rows={leaveRows()} today={today()} />
    </AppShell>
  )
}
app/people/leave/data.ts
/**
 * What the leave page reads: every `db.leaves` request with its employee
 * resolved against `db.employees`, so each row carries a face, a role and a
 * department. The month the strip draws is REFERENCE_DATE's; every figure
 * over the table and every count on the strip is worked out from these rows
 * in the island that holds them, so a decision moves them all at once.
 * Leave names days, not instants: `from` and `to` are midnight UTC and both
 * are days away. "Now" is `REFERENCE_DATE`.
 */
import { formatDate, getInitials } from "@/lib/format"
import { avatarFor, db, REFERENCE_DATE, type Leave, type Member } from "@/lib/sample-data"

/** One row of the leave table. */
export type LeaveRow = {
  id: string
  employee: { id: string; name: string; role: string; department: string; avatarUrl?: string }
  kind: Leave["kind"]
  from: Date
  to: Date
  status: Leave["status"]
  note?: string
}

const ORDER: Record<Leave["status"], number> = { pending: 0, approved: 1, declined: 2 }

/** Every request, what is waiting first and then by the day it starts. */
export function leaveRows(): LeaveRow[] {
  const employees = new Map(db.employees.all().map((employee) => [employee.id, employee]))
  return db.leaves
    .all()
    .map((leave) => {
      const employee = employees.get(leave.employeeId)
      const name = employee?.name ?? "Former colleague"
      return {
        id: leave.id,
        employee: {
          id: leave.employeeId,
          name,
          role: employee?.role ?? "—",
          department: employee?.department ?? "—",
          avatarUrl: employee ? (employee.avatarUrl ?? avatarFor(name)) : undefined,
        },
        kind: leave.kind,
        from: leave.from,
        to: leave.to,
        status: leave.status,
        ...(leave.note ? { note: leave.note } : {}),
      }
    })
    .sort((a, b) => ORDER[a.status] - ORDER[b.status] || a.from.getTime() - b.from.getTime())
}

/** Midnight UTC on the day "now" falls on — the day the strip opens on. */
export function today(): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

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/people/leave/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing only this page changes. Deciding a request is the
 * dashboard's — the overview's leave queue does it too — so it lives once, as
 * `decideLeave` in `@/lib/dashboards/people/actions`, and the
 * leave table imports it from there.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/people/leave/components/leave-columns.tsx
"use client"

import { CheckIcon, XIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"

import { type LeaveRow } from "../data"
import { daysIn, KIND_LABELS, spanLabel, STATUS_LABELS, STATUS_MAP } from "./leave-vocabulary"

export type LeaveColumnHandlers = {
  onDecide: (row: LeaveRow, decision: "approved" | "declined") => void
}

/**
 * Columns step in as the table's own frame allows — a container query, not
 * the viewport, because at 768 and 1024 the sidebar leaves the table less
 * room than a phone's width suggests. Below 44rem: who, and the decision,
 * with the kind and the dates under the name. From 44rem the kind, the dates
 * and the days take columns of their own; from 52rem the note joins them.
 */
const FROM = {
  dates: "hidden @min-[44rem]/leave:table-cell",
  note: "hidden @min-[52rem]/leave:table-cell",
} as const

/**
 * The leave table's columns. A pending request carries its two decisions as
 * buttons in the row — the work this page exists for, one press away — each
 * named for the person, the kind and the days, so a reader tabbing down the
 * table hears which request a button decides. A decided request carries its
 * pill instead.
 */
export function leaveColumns({ onDecide }: LeaveColumnHandlers): DataTableColumnDef<LeaveRow>[] {
  return [
    {
      id: "employee",
      accessorFn: (row) => `${row.employee.name} ${row.employee.department} ${row.note ?? ""}`,
      header: ({ column }) => <DataTableColumnHeader column={column} title="Employee" />,
      cell: ({ row }) => (
        <UserCell
          size="sm"
          name={row.original.employee.name}
          description={
            // In a narrow frame the kind and the dates ride under the name, where their columns cannot fit.
            <>
              <span className="@min-[44rem]/leave:hidden">
                {`${KIND_LABELS[row.original.kind]} · ${spanLabel(row.original.from, row.original.to)}`}
              </span>
              <span className="hidden @min-[44rem]/leave:inline">{`${row.original.employee.role} · ${row.original.employee.department}`}</span>
            </>
          }
          src={row.original.employee.avatarUrl}
        />
      ),
      meta: { label: "Employee" },
    },
    {
      accessorKey: "kind",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Kind" />,
      cell: ({ row }) => <Badge variant="outline">{KIND_LABELS[row.original.kind]}</Badge>,
      meta: { label: "Kind", className: FROM.dates },
    },
    {
      accessorKey: "from",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Dates" />,
      cell: ({ row }) => (
        <span className="whitespace-nowrap tabular-nums">{spanLabel(row.original.from, row.original.to)}</span>
      ),
      meta: { label: "Dates", className: FROM.dates },
    },
    {
      id: "days",
      accessorFn: (row) => daysIn(row.from, row.to),
      header: ({ column }) => <DataTableColumnHeader column={column} title="Days" />,
      cell: ({ row }) => <NumberCell value={daysIn(row.original.from, row.original.to)} />,
      meta: { align: "right", label: "Days", className: FROM.dates },
    },
    {
      accessorKey: "note",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Note" />,
      enableSorting: false,
      cell: ({ row }) => (
        <TruncateCell maxWidth={180} className="text-muted-foreground">
          {row.original.note ?? "—"}
        </TruncateCell>
      ),
      meta: { label: "Note", className: FROM.note },
    },
    {
      accessorKey: "status",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
      cell: ({ row }) => {
        const { status, employee, kind, from, to } = row.original
        if (status !== "pending") {
          return <StatusBadge status={status} map={STATUS_MAP} label={STATUS_LABELS[status]} />
        }
        const what = `${employee.name}'s ${KIND_LABELS[kind].toLowerCase()} leave, ${spanLabel(from, to)}`
        return (
          <div className="flex items-center gap-1.5">
            <Button size="xs" variant="outline" aria-label={`Approve ${what}`} onClick={() => onDecide(row.original, "approved")}>
              <CheckIcon aria-hidden="true" />
              <span className="sr-only @md/leave:not-sr-only">Approve</span>
            </Button>
            <Button size="xs" variant="ghost" aria-label={`Decline ${what}`} onClick={() => onDecide(row.original, "declined")}>
              <XIcon aria-hidden="true" />
              <span className="sr-only @md/leave:not-sr-only">Decline</span>
            </Button>
          </div>
        )
      },
      meta: { label: "Status" },
    },
  ]
}
app/people/leave/components/leave-stats.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type LeaveRow } from "../data"
import { covers, isWorkday, KIND_LABELS } from "./leave-vocabulary"

const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

export type LeaveStatsProps = {
  rows: LeaveRow[]
  /** Midnight UTC on the day "now" falls on. */
  today: Date
  /** Midnight UTC on every day of today's month. */
  days: number[]
}

/**
 * The four figures over the table, counted from the rows the table holds, so
 * a decision moves them in the render that turns its pill: what is waiting,
 * who is out today, how many working days of approved leave fall in the month
 * — a weekend inside a fortnight off costs no work, so it is not counted —
 * and how the decided requests went.
 */
export function LeaveStats({ rows, today, days }: LeaveStatsProps) {
  const pending = rows.filter((row) => row.status === "pending")
  const approved = rows.filter((row) => row.status === "approved")
  const declined = rows.filter((row) => row.status === "declined").length
  const out = approved.filter((row) => covers(row, today.getTime()))
  const booked = days.filter(isWorkday).reduce((sum, day) => sum + approved.filter((row) => covers(row, day)).length, 0)
  const next = [...pending].sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime())[0]

  return (
    <StatCardGroup data-widget="widget-people-leave-leave-stats" columns={4}>
      <StatCard
        label="Waiting on you"
        value={formatNumber(pending.length)}
        description={next ? `the soonest starts ${SHORT.format(new Date(next.from))}` : "nothing to decide"}
        tone={pending.length > 0 ? "warning" : "neutral"}
      />
      <StatCard
        label="Out today"
        value={formatNumber(out.length)}
        description={
          out.length === 0
            ? "everyone is in"
            : out.map((row) => `${row.employee.name.split(" ")[0]} (${KIND_LABELS[row.kind].toLowerCase()})`).slice(0, 2).join(", ") +
              (out.length > 2 ? ` and ${out.length - 2} more` : "")
        }
      />
      <StatCard
        label="Working days off"
        value={formatNumber(booked)}
        description={`approved, across ${MONTH.format(today)}`}
      />
      <StatCard label="Approved" value={formatNumber(approved.length)} description={`${declined} declined`} />
    </StatCardGroup>
  )
}
app/people/leave/components/leave-view.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Callout } from "@/components/ui/callout"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"

import { decideLeave } from "@/lib/dashboards/people/actions"
import { type LeaveRow } from "../data"
import { leaveColumns } from "./leave-columns"
import { LeaveStats } from "./leave-stats"
import { KIND_LABELS, monthDays, spanLabel, STATUS_LABELS } from "./leave-vocabulary"
import { MonthStrip } from "./month-strip"

const FACETS: DataTableFacet<LeaveRow>[] = [
  {
    columnId: "status",
    title: "Status",
    options: Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value })),
  },
  {
    columnId: "kind",
    title: "Kind",
    options: Object.entries(KIND_LABELS).map(([value, label]) => ({ label, value })),
  },
]

/**
 * The figures, the month strip and the table, held in one island so a
 * decision moves all three: the row takes the status the `Result` carries,
 * the strip recounts who is out on each of its days, and the figures recount
 * what is waiting. A success is said in the status line; a refusal — a request
 * someone else decided first, one gone from the store — in an alert above the
 * table. The two never share a slot.
 */
export function LeaveView({ titleId, rows: initial, today }: { titleId: string; rows: LeaveRow[]; today: Date }) {
  const [rows, setRows] = React.useState(initial)
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState<string | null>(null)
  const days = React.useMemo(() => monthDays(new Date(today)), [today])

  const decide = React.useCallback(async (row: LeaveRow, decision: "approved" | "declined") => {
    setNotice("")
    setRefusal(null)
    const result = await decideLeave(row.id, decision)
    if (!result.ok) return setRefusal(result.error.message)
    setRows((current) => current.map((entry) => (entry.id === row.id ? { ...entry, status: result.data.status } : entry)))
    const verb = decision === "approved" ? "Approved" : "Declined"
    setNotice(`${verb} ${result.data.employeeName}'s ${result.data.kind} leave, ${spanLabel(row.from, row.to)}.`)
  }, [])

  const columns = React.useMemo(() => leaveColumns({ onDecide: (row, decision) => void decide(row, decision) }), [decide])

  return (
    <div className="flex flex-col gap-[var(--density-gap,1rem)]">
      <LeaveStats rows={rows} today={new Date(today)} days={days} />

      <MonthStrip rows={rows} days={days} today={new Date(today)} />

      {/* The container the table's columns measure themselves against. */}
      <div className="@container/leave flex flex-col gap-3">
        {refusal ? (
          <Callout variant="danger" role="alert" title="Not decided">
            {refusal}
          </Callout>
        ) : null}
        <p
          data-slot="leave-status"
          role="status"
          aria-live="polite"
          className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
        >
          {notice}
        </p>

        <DataTable
          aria-labelledby={titleId}
          columns={columns}
          data={rows}
          facets={FACETS}
          searchKey="employee"
          searchPlaceholder="Search people"
          getRowId={(row) => row.id}
          enableRowSelection={false}
          emptyMessage="No requests match these filters."
          pageSizeOptions={[10, 25, 50]}
        />
      </div>
    </div>
  )
}
app/people/leave/components/leave-vocabulary.ts
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words this page puts on a kind of leave and a decision. Vocabulary, not
 * data: it lives beside the islands that print it, because `data.ts` reads
 * `db` and must never reach the browser. The people overview's queue prints
 * the same words.
 */

export const KIND_LABELS: Record<string, string> = {
  annual: "Annual",
  sick: "Sick",
  parental: "Parental",
  unpaid: "Unpaid",
}

export const STATUS_LABELS: Record<string, string> = { pending: "Pending", approved: "Approved", declined: "Declined" }

export const STATUS_MAP: Record<string, StatusVariant> = { pending: "warning", approved: "success", declined: "danger" }

const DAY_MS = 86_400_000

/** Both ends included: a request from Monday to Friday is five days. */
export function daysIn(from: Date, to: Date): number {
  return Math.round((new Date(to).getTime() - new Date(from).getTime()) / DAY_MS) + 1
}

/** Whether the day that starts at `day` (midnight UTC) is a working day, Monday to Friday. */
export function isWorkday(day: number): boolean {
  const weekday = new Date(day).getUTCDay()
  return weekday !== 0 && weekday !== 6
}

/** Midnight UTC on every day of the month `today` falls in — the strip's days, and the figures' month. */
export function monthDays(today: Date): number[] {
  const year = today.getUTCFullYear()
  const month = today.getUTCMonth()
  const count = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
  return Array.from({ length: count }, (_, index) => Date.UTC(year, month, index + 1))
}

/** Whether a request covers the day that starts at `day` (midnight UTC). */
export function covers(leave: { from: Date; to: Date }, day: number): boolean {
  return new Date(leave.from).getTime() <= day && day <= new Date(leave.to).getTime()
}

const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** "Sep 7 – Sep 13", or "Sep 7" for a single day. */
export function spanLabel(from: Date, to: Date): string {
  const start = SHORT.format(new Date(from))
  const end = SHORT.format(new Date(to))
  return start === end ? start : `${start} – ${end}`
}
app/people/leave/components/month-strip.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type LeaveRow } from "../data"
import { covers, isWorkday, KIND_LABELS, spanLabel } from "./leave-vocabulary"

const WEEKDAY = new Intl.DateTimeFormat("en-US", { weekday: "narrow", timeZone: "UTC" })
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })

export type MonthStripProps = {
  rows: LeaveRow[]
  /** Midnight UTC on every day of the month, first to last. */
  days: number[]
  today: Date
}

/**
 * The month as one strip of days: each day carries how many people are out
 * on approved leave, and how many more have asked and are waiting. Picking a
 * day — by pointer, or with the arrow keys, since the strip is one radio
 * group with one tab stop — lists who is out on it and who has asked.
 * Weekends sit on the muted plane; today's number is set in ink; the picked
 * day takes the accent's tint. On a phone the strip folds into weeks.
 */
export function MonthStrip({ rows, days, today }: MonthStripProps) {
  const [picked, setPicked] = React.useState(today.getTime())
  const refs = React.useRef(new Map<number, HTMLButtonElement | null>())

  const approved = rows.filter((row) => row.status === "approved")
  const pending = rows.filter((row) => row.status === "pending")
  const counts = days.map((day) => ({
    day,
    out: approved.filter((row) => covers(row, day)),
    asked: pending.filter((row) => covers(row, day)),
  }))
  const current = counts.find((entry) => entry.day === picked) ?? counts[0]
  // Monday-first weeks on a phone, so the first day sits under its own weekday.
  const lead = (new Date(days[0]).getUTCDay() + 6) % 7

  function move(index: number) {
    const next = counts[Math.min(Math.max(index, 0), counts.length - 1)]
    setPicked(next.day)
    refs.current.get(next.day)?.focus()
  }

  function onKeyDown(event: React.KeyboardEvent, index: number) {
    const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 7, ArrowUp: -7 }
    if (event.key in step) {
      event.preventDefault()
      move(index + step[event.key])
    } else if (event.key === "Home" || event.key === "End") {
      event.preventDefault()
      move(event.key === "Home" ? 0 : counts.length - 1)
    }
  }

  const label = LONG.format(new Date(current.day))

  return (
    <Widget
      data-widget="widget-people-leave-month-strip"
      title={`Who is out in ${MONTH.format(today)}`}
      description="Approved leave by day, with the requests still waiting on a decision"
    >
      <div className="flex flex-col gap-4">
        <div
          role="radiogroup"
          aria-label={`Days of ${MONTH.format(today)}`}
          data-slot="month-strip"
          className="grid grid-cols-7 gap-1 md:grid-flow-col md:grid-cols-none md:auto-cols-fr"
        >
          {Array.from({ length: lead }, (_, index) => (
            <span key={`lead-${index}`} aria-hidden="true" className="md:hidden" />
          ))}
          {counts.map(({ day, out, asked }, index) => {
            const date = new Date(day)
            const weekend = !isWorkday(day)
            const isToday = day === today.getTime()
            const selected = day === picked
            return (
              <button
                key={day}
                ref={(node) => {
                  refs.current.set(day, node)
                }}
                type="button"
                role="radio"
                aria-checked={selected}
                aria-label={`${LONG.format(date)}: ${out.length} out${asked.length > 0 ? `, ${asked.length} waiting` : ""}`}
                data-out={out.length}
                data-pending={asked.length}
                tabIndex={selected ? 0 : -1}
                onClick={() => setPicked(day)}
                onKeyDown={(event) => onKeyDown(event, index)}
                className={cn(
                  "flex min-w-0 flex-col items-center gap-1 rounded-md px-0.5 py-1.5 transition-colors duration-(--duration-fast) focus-ring hover:bg-muted",
                  weekend && "bg-muted/50",
                  selected && "bg-brand-muted font-medium hover:bg-brand-muted"
                )}
              >
                <span aria-hidden="true" className="text-2xs text-muted-foreground">
                  {WEEKDAY.format(date)}
                </span>
                <span
                  aria-hidden="true"
                  className={cn(
                    "grid size-6 place-items-center rounded-full text-xs tabular-nums",
                    isToday && "bg-foreground text-background"
                  )}
                >
                  {date.getUTCDate()}
                </span>
                <span aria-hidden="true" className="flex h-4 items-center gap-0.5 text-2xs tabular-nums">
                  {out.length > 0 ? <span className="font-semibold text-foreground">{out.length}</span> : <span className="text-faint-foreground">·</span>}
                  {asked.length > 0 ? <span className="text-warning">+{asked.length}</span> : null}
                </span>
              </button>
            )
          })}
        </div>

        <div className="grid gap-4 border-t border-rule pt-4 sm:grid-cols-2">
          <div className="flex min-w-0 flex-col gap-2">
            <p className="text-xs font-medium text-muted-foreground">Out on {label}</p>
            {current.out.length === 0 ? (
              <p className="text-sm text-muted-foreground">Nobody is out.</p>
            ) : (
              <ul aria-label={`Out on ${label}`} className="flex flex-col gap-2">
                {current.out.map((row) => (
                  <li key={row.id} className="flex items-center justify-between gap-3">
                    <UserCell size="sm" name={row.employee.name} description={spanLabel(row.from, row.to)} src={row.employee.avatarUrl} className="min-w-0" />
                    <Badge variant="outline" className="shrink-0">
                      {KIND_LABELS[row.kind]}
                    </Badge>
                  </li>
                ))}
              </ul>
            )}
          </div>
          <div className="flex min-w-0 flex-col gap-2">
            <p className="text-xs font-medium text-muted-foreground">Asked for, waiting on you</p>
            {current.asked.length === 0 ? (
              <p className="text-sm text-muted-foreground">No requests waiting for this day.</p>
            ) : (
              <ul aria-label={`Asked for ${label}`} className="flex flex-col gap-2">
                {current.asked.map((row) => (
                  <li key={row.id} className="flex items-center justify-between gap-3">
                    <UserCell size="sm" name={row.employee.name} description={spanLabel(row.from, row.to)} src={row.employee.avatarUrl} className="min-w-0" />
                    <Badge variant="outline" className="shrink-0">
                      {KIND_LABELS[row.kind]}
                    </Badge>
                  </li>
                ))}
              </ul>
            )}
          </div>
        </div>
      </div>
    </Widget>
  )
}