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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-people-leave-leave-statsNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }))
}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