Skip to contentVibraUI

Leave figures

What is waiting on a decision, who is out today, the working days of approved leave this month and how the decided requests went; reads leaveRows().

Preview

Install

npx shadcn@latest add @vibra/widget-people-leave-leave-stats

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

Source

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/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