Skip to contentVibraUI

Departments

Headcount by department as one segmented bar, then a tile a department with its count and its share, in the bar's own colours; reads departments().

Preview

Install

npx shadcn@latest add @vibra/widget-people-overview-departments

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

Source

app/people/components/departments.tsx
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { CHART_BG, CHART_TOKENS, PercentageBar } from "@/components/ui/percentage-bar"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { Widget } from "@/components/ui/widget"

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

export type DepartmentsProps = { departments: DepartmentCount[] }

/**
 * Headcount by department: one segmented bar, then a tile per department
 * with its count and its share — the kit's small stat tiles in one divided
 * group. The tiles take the palette in the bar's own order, so a segment
 * reads back to its tile by colour.
 */
export function Departments({ departments }: DepartmentsProps) {
  return (
    <Widget
      data-widget="widget-people-overview-departments"
      title="Departments"
      description="Where the headcount sits"
      className="h-full"
    >
      <PercentageBar
        segments={departments.map((entry) => ({ label: entry.department, value: entry.count }))}
        showLegend={false}
        aria-label="Headcount by department"
      />
      <StatCardGroup columns={4} divided role="group" aria-label="Departments by headcount" className="mt-4">
        {departments.map((entry, index) => (
          <StatCard
            key={entry.department}
            size="sm"
            label={
              <span className="flex min-w-0 items-center gap-1.5">
                <span
                  aria-hidden="true"
                  className={cn("size-2 shrink-0 rounded-full", CHART_BG[CHART_TOKENS[index % CHART_TOKENS.length]])}
                />
                <span className="truncate">{entry.department}</span>
              </span>
            }
            value={formatNumber(entry.count)}
            description={`${entry.share}% of the headcount`}
          />
        ))}
      </StatCardGroup>
    </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 }
}

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