Skip to contentVibraUI

Work calendar

The month with a count on every day that has something on it, and the picked day's meetings, deadlines, releases and time off; reads calendarEvents().

Preview

Install

npx shadcn@latest add @vibra/widget-people-overview-work-calendar

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

Source

app/people/components/work-calendar.tsx
"use client"

import * as React from "react"

import { DataList, DataListItem } from "@/components/ui/data-list"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

import { type CalendarEvent } from "../data"

import { EVENT_KIND_LABELS, EVENT_KIND_MAP } from "./people-vocabulary"

const DAY = 86_400_000

const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const PICKED = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })

/** Midnight UTC on the day `date` falls on, as the calendar keys it. */
const dayOf = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const keyOf = (day: number): string => new Date(day).toISOString().slice(0, 10)

/** Every day an event touches, from the day it starts to the day it ends. */
function daysOf(event: CalendarEvent): number[] {
  const first = dayOf(event.startsAt)
  const last = Math.max(first, dayOf(event.endsAt))
  return Array.from({ length: (last - first) / DAY + 1 }, (_, index) => first + index * DAY)
}

function timeOf(event: CalendarEvent): string {
  if (event.allDay) return "All day"
  return `${TIME.format(event.startsAt)} – ${TIME.format(event.endsAt)}`
}

export type WorkCalendarProps = {
  events: CalendarEvent[]
  /** Today, at midnight UTC — the day the calendar opens on. */
  today: Date
}

/**
 * The month with a count on every day that has something on it, and the
 * picked day's entries under it. Every date is read through its UTC parts,
 * so the day under the cursor is the same day for every reader.
 */
export function WorkCalendar({ events, today }: WorkCalendarProps) {
  const [month, setMonth] = React.useState(today)
  const [picked, setPicked] = React.useState<Date | undefined>(today)

  const marks = React.useMemo(() => {
    const out: Record<string, MiniCalendarMark> = {}
    for (const event of events) {
      for (const day of daysOf(event)) {
        const key = keyOf(day)
        out[key] = { count: (out[key]?.count ?? 0) + 1 }
      }
    }
    return out
  }, [events])

  const day = picked ? dayOf(picked) : undefined
  const onDay = day === undefined ? [] : events.filter((event) => daysOf(event).includes(day))

  return (
    <Widget
      data-widget="widget-people-overview-work-calendar"
      title="Work calendar"
      description="Meetings, deadlines and time off"
      className="h-full"
    >
      <div className="flex flex-col gap-3">
        <MiniCalendar
          size="sm"
          month={month}
          onMonthChange={setMonth}
          value={picked}
          onValueChange={setPicked}
          marks={marks}
          today={today}
          className="w-full"
        />
        <p data-slot="picked-day" className="text-xs font-medium text-muted-foreground">
          {picked ? PICKED.format(picked) : "Pick a day."}
        </p>
        {onDay.length > 0 ? (
          <DataList divided>
            {onDay.map((event) => (
              <DataListItem
                key={event.id}
                title={event.title}
                description={
                  <span className="tabular-nums">
                    {event.location ? `${timeOf(event)} · ${event.location}` : timeOf(event)}
                  </span>
                }
                wrapTitle
                actions={
                  <StatusBadge
                    status={event.kind}
                    label={EVENT_KIND_LABELS[event.kind]}
                    map={EVENT_KIND_MAP}
                    size="sm"
                  />
                }
              />
            ))}
          </DataList>
        ) : (
          <p className="py-4 text-center text-sm text-muted-foreground">Nothing on this day.</p>
        )}
      </div>
    </Widget>
  )
}
app/people/data.ts
/**
 * What /hr reads. The people are `db.employees`, the queue is `db.leaves`, the
 * calendar is `db.events`. The week's attendance and the arrival grid have no
 * rows behind them, so they are generated once from `seeded("dashboard-hr")`
 * — sized from the payroll's own mean attendance, so the chart and the tile
 * above it print the same rate. "Now" is `REFERENCE_DATE`; the week is the
 * Monday-to-Friday one it falls in.
 *
 * The client islands import only the types below; `db` never crosses into the
 * browser.
 */
import { formatDate, formatNumber, formatPercent, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Employee, type Event, type Leave, type Member } from "@/lib/sample-data"

const DAY = 86_400_000

/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

export const TODAY = utcDay(REFERENCE_DATE)

/** Monday of the week today falls in. */
const MONDAY = TODAY - ((new Date(TODAY).getUTCDay() + 6) % 7) * DAY

const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"]
const HOURS = ["08:00", "09:00", "10:00", "11:00", "12:00"]
/** How a morning's arrivals fall by hour: most people are in by ten. */
const HOUR_SHAPE = [0.24, 0.46, 0.19, 0.08, 0.03]

const employees = () => db.employees.all()

const mean = (values: number[]): number =>
  values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length

/** The change from `before` to `now` as a ratio; flat when there was nothing before. */
const change = (now: number, before: number): number => (before === 0 ? 0 : (now - before) / before)

export type WeekDay = { day: string; date: string; onTime: number; late: number; absent: number }

export type AttendanceWeek = {
  days: WeekDay[]
  /** The share of the week's working days that were attended. */
  rate: number
  /** The same measure over the week before, from the seeded rule. */
  lastWeekRate: number
}

type Seeded = {
  week: AttendanceWeek
  arrivals: number[][]
  typeDeltas: Record<Employee["type"], number>
}

/**
 * Everything on the page with no rows behind it, from one generator: the
 * week's absences are sized so the week's rate is the payroll's mean
 * attendance to the nearest whole absence, then spread over the five days
 * with Monday and Friday a little heavier; the arrival grid follows the
 * morning's shape; the contract-type deltas are a small drift either way.
 */
function generate(): Seeded {
  const rand = seeded("dashboard-hr")
  const staff = employees()
  const headcount = staff.length
  const attendance = mean(staff.map((row) => row.attendanceRate))
  const totalAbsent = Math.round(5 * headcount * (1 - attendance))

  const weights = [1.15, 0.9, 0.85, 0.95, 1.15].map((weight) => weight * (0.85 + rand() * 0.3))
  const total = weights.reduce((sum, weight) => sum + weight, 0)
  let assigned = 0
  const absents = weights.map((weight, index) => {
    if (index === weights.length - 1) return totalAbsent - assigned
    const share = Math.round((totalAbsent * weight) / total)
    assigned += share
    return share
  })

  const days = absents.map((absent, index) => {
    const present = headcount - absent
    const late = Math.round(present * (0.05 + rand() * 0.07))
    return {
      day: WEEKDAYS[index],
      date: new Date(MONDAY + index * DAY).toISOString().slice(0, 10),
      onTime: present - late,
      late,
      absent,
    }
  })

  const rate = 1 - totalAbsent / (5 * headcount)
  const lastWeekRate = rate - 0.03 + rand() * 0.05

  // Arrivals by hour, one column per weekday: the day's on-time and late
  // arrivals spread over the morning's shape, with a little noise per cell.
  const arrivals = HOURS.map((_, hour) =>
    days.map((day) => Math.round((day.onTime + day.late) * HOUR_SHAPE[hour] * (0.85 + rand() * 0.3)))
  )

  return {
    week: { days, rate, lastWeekRate },
    arrivals,
    typeDeltas: { fulltime: 0.01 + rand() * 0.04, contract: -0.06 + rand() * 0.1 },
  }
}

// Drawn per call: the draws are a fixed number in a fixed order whatever the
// payroll holds, so they are the same on every render, while the headcount
// they are sized to is the payroll as it stands — a hire or a leaver since the
// server started moves the week.

/** Monday to Friday of this week, and the rate over it. */
export function attendanceWeek(): AttendanceWeek {
  return generate().week
}

export type ArrivalsGrid = { rows: string[]; columns: string[]; values: number[][] }

/** When people clock in: an hour × weekday grid of arrival counts. */
export function arrivals(): ArrivalsGrid {
  return { rows: HOURS, columns: WEEKDAYS, values: generate().arrivals }
}

export type PeopleStat = {
  key: "headcount" | "attendance" | "leaves" | "kpi"
  label: string
  value: string
  /** Change against the period before, as a ratio. */
  delta?: number
  description: string
}

/** The four headline numbers. */
export function peopleStats(): PeopleStat[] {
  const staff = employees()
  const remote = staff.filter((row) => row.status === "remote").length
  const away = staff.filter((row) => row.status === "on_leave").length
  // Who was already here when the quarter opened: the payroll grows slowly,
  // so a month is too short a window to show it moving.
  const quarterStart = Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3, 1)
  const atQuarterStart = staff.filter((row) => row.startedAt.getTime() < quarterStart).length
  const waiting = db.leaves.all().filter((leave) => leave.status === "pending").length
  const { rate, lastWeekRate } = generate().week

  return [
    {
      key: "headcount",
      label: "Headcount",
      value: formatNumber(staff.length),
      delta: change(staff.length, atQuarterStart),
      description: `${remote} remote · ${away} on leave`,
    },
    {
      key: "attendance",
      label: "Attendance",
      value: formatPercent(rate, { maximumFractionDigits: 1 }),
      delta: change(rate, lastWeekRate),
      description: "vs last week",
    },
    {
      key: "leaves",
      label: "Leave requests",
      value: formatNumber(waiting),
      description: "awaiting a decision",
    },
    {
      key: "kpi",
      label: "Average KPI",
      value: mean(staff.map((row) => row.kpi)).toFixed(1),
      description: "out of 10",
    },
  ]
}

export type TypeCount = { type: Employee["type"]; count: number; delta: number }

/** Full-time and contract headcount, each with its drift since last quarter. */
export function headcountByType(): TypeCount[] {
  const staff = employees()
  const { typeDeltas } = generate()
  return (["fulltime", "contract"] as const).map((type) => ({
    type,
    count: staff.filter((row) => row.type === type).length,
    delta: typeDeltas[type],
  }))
}

export type CalendarEvent = Pick<Event, "id" | "title" | "kind" | "startsAt" | "endsAt" | "allDay" | "location">

/** Every calendar entry, for the mini calendar to mark and list. */
export function calendarEvents(): CalendarEvent[] {
  return db.events
    .all()
    .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
    .map(({ id, title, kind, startsAt, endsAt, allDay, location }) => ({
      id,
      title,
      kind,
      startsAt,
      endsAt,
      ...(allDay ? { allDay } : {}),
      ...(location ? { location } : {}),
    }))
}

/** One row of the employees table. */
export type EmployeeRow = Pick<
  Employee,
  "id" | "name" | "email" | "avatarUrl" | "department" | "role" | "type" | "status" | "startedAt"
>

/** Everyone on the payroll; the table sorts and pages them itself. */
export function employeeRows(): EmployeeRow[] {
  return employees().map(({ id, name, email, avatarUrl, department, role, type, status, startedAt }) => ({
    id,
    name,
    email,
    avatarUrl,
    department,
    role,
    type,
    status,
    startedAt,
  }))
}

/** One request in the queue, with the person resolved. */
export type LeaveRow = {
  id: string
  employeeId: string
  employeeName: string
  avatarUrl?: string
  role: string
  kind: Leave["kind"]
  from: Date
  to: Date
  status: Leave["status"]
  note?: string
}

/** The requests still waiting, soonest first. */
export function leaveQueue(): LeaveRow[] {
  const byId = new Map(employees().map((row) => [row.id, row]))
  return db.leaves
    .all()
    .filter((leave) => leave.status === "pending")
    .sort((a, b) => a.from.getTime() - b.from.getTime())
    .map((leave) => {
      const employee = byId.get(leave.employeeId)
      return {
        id: leave.id,
        employeeId: leave.employeeId,
        employeeName: employee?.name ?? "A colleague",
        avatarUrl: employee?.avatarUrl,
        role: employee?.role ?? "—",
        kind: leave.kind,
        from: leave.from,
        to: leave.to,
        status: leave.status,
        ...(leave.note ? { note: leave.note } : {}),
      }
    })
}

export type DepartmentCount = { department: Employee["department"]; count: number; share: number }

/** Headcount by department, largest first, each with its share of the whole. */
export function departments(): DepartmentCount[] {
  const staff = employees()
  const counts = new Map<Employee["department"], number>()
  for (const row of staff) counts.set(row.department, (counts.get(row.department) ?? 0) + 1)
  return [...counts.entries()]
    .map(([department, count]) => ({
      department,
      count,
      share: Math.round((count / staff.length) * 100),
    }))
    .sort((a, b) => b.count - a.count)
}

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

/** The line under the title: the week, and when the numbers were read. */
export function lastUpdated(): string {
  return `Week of ${WEEK_OF.format(new Date(MONDAY))} · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

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

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 }
}
app/people/components/people-vocabulary.ts
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words this page puts on a contract type, a status, a kind of leave and
 * a calendar entry. Vocabulary, not data — it lives beside the islands that
 * print it rather than in `data.ts`, which reads `db` and must never reach
 * the browser.
 */
export const TYPE_LABELS: Record<string, string> = {
  fulltime: "Full-time",
  contract: "Contract",
}

export const EMPLOYEE_STATUS_LABELS: Record<string, string> = {
  active: "In office",
  remote: "Remote",
  on_leave: "On leave",
}

export const EMPLOYEE_STATUS_MAP: Record<string, StatusVariant> = {
  active: "success",
  remote: "info",
  on_leave: "warning",
}

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

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

export const EVENT_KIND_LABELS: Record<string, string> = {
  meeting: "Meeting",
  deadline: "Deadline",
  release: "Release",
  ooo: "Out of office",
}

export const EVENT_KIND_MAP: Record<string, StatusVariant> = {
  meeting: "info",
  deadline: "danger",
  release: "success",
  ooo: "neutral",
}

export const DEPARTMENTS = ["Engineering", "Design", "Sales", "Marketing", "Finance", "Support", "People"]

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 People dashboard page