Skip to contentVibraUI

Who is out this month

The month as one strip of days, each with how many are out on approved leave and how many more have asked, the picked day's names listed under it; reads leaveRows().

Preview

Install

npx shadcn@latest add @vibra/widget-people-leave-month-strip

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

Source

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>
  )
}
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/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}`
}

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 Leave page