Attendance this week
Monday to Friday stacked as on time, late and absent, with the week's rate against last week's and how many arrived late; reads attendanceWeek().
Preview
"use client"
import { formatNumber, formatPercent } from "@/lib/format"
import { chartTone } from "@/components/ui/chart-core"
import { MetricDelta } from "@/components/ui/metric-delta"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { Widget } from "@/components/ui/widget"
import { type AttendanceWeek } from "../data"
// Attendance is a meaning, not a category, so each series resolves to a
// semantic token: the people who were in on time, the ones who were late, and
// the ones who were not in at all. The words are in the legend; the colour
// only reinforces them.
const SERIES = [
{ key: "onTime", label: "On time", color: chartTone("positive") },
{ key: "late", label: "Late", color: chartTone("warning") },
{ key: "absent", label: "Absent", color: chartTone("negative") },
]
export type AttendanceChartProps = { week: AttendanceWeek }
/**
* Monday to Friday as a stack of on time, late and absent, with the week's
* rate and its move on last week above the plot. A client island only because
* the chart is one; the series arrives computed.
*/
export function AttendanceChart({ week }: AttendanceChartProps) {
const present = week.days.reduce((sum, day) => sum + day.onTime + day.late, 0)
const late = week.days.reduce((sum, day) => sum + day.late, 0)
const delta = week.lastWeekRate === 0 ? 0 : (week.rate - week.lastWeekRate) / week.lastWeekRate
return (
<Widget
data-widget="widget-people-overview-attendance-chart"
title="Attendance this week"
description="Who was in, by day"
className="h-full"
>
<div className="flex flex-col gap-4">
<div data-slot="attendance-summary" className="flex flex-wrap items-end gap-x-6 gap-y-2">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="type-numeral text-2xl">
{formatPercent(week.rate, { maximumFractionDigits: 1 })}
</span>
<MetricDelta value={delta} variant="pill" size="sm" />
</div>
<span className="text-xs text-muted-foreground">present this week</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="type-numeral text-2xl">{formatNumber(present)}</span>
<span className="text-xs text-muted-foreground">attendances over five days</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="type-numeral text-2xl">{formatNumber(late)}</span>
<span className="text-xs text-muted-foreground">arrived late</span>
</div>
</div>
<StackedBarChart
data={week.days}
index="day"
series={SERIES}
height={220}
valueFormatter={(value) => formatNumber(value)}
aria-label="Attendance by weekday"
/>
</div>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-people-overview-attendance-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatNumber, formatPercent } from "@/lib/format"
import { chartTone } from "@/components/ui/chart-core"
import { MetricDelta } from "@/components/ui/metric-delta"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { Widget } from "@/components/ui/widget"
import { type AttendanceWeek } from "../data"
// Attendance is a meaning, not a category, so each series resolves to a
// semantic token: the people who were in on time, the ones who were late, and
// the ones who were not in at all. The words are in the legend; the colour
// only reinforces them.
const SERIES = [
{ key: "onTime", label: "On time", color: chartTone("positive") },
{ key: "late", label: "Late", color: chartTone("warning") },
{ key: "absent", label: "Absent", color: chartTone("negative") },
]
export type AttendanceChartProps = { week: AttendanceWeek }
/**
* Monday to Friday as a stack of on time, late and absent, with the week's
* rate and its move on last week above the plot. A client island only because
* the chart is one; the series arrives computed.
*/
export function AttendanceChart({ week }: AttendanceChartProps) {
const present = week.days.reduce((sum, day) => sum + day.onTime + day.late, 0)
const late = week.days.reduce((sum, day) => sum + day.late, 0)
const delta = week.lastWeekRate === 0 ? 0 : (week.rate - week.lastWeekRate) / week.lastWeekRate
return (
<Widget
data-widget="widget-people-overview-attendance-chart"
title="Attendance this week"
description="Who was in, by day"
className="h-full"
>
<div className="flex flex-col gap-4">
<div data-slot="attendance-summary" className="flex flex-wrap items-end gap-x-6 gap-y-2">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="type-numeral text-2xl">
{formatPercent(week.rate, { maximumFractionDigits: 1 })}
</span>
<MetricDelta value={delta} variant="pill" size="sm" />
</div>
<span className="text-xs text-muted-foreground">present this week</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="type-numeral text-2xl">{formatNumber(present)}</span>
<span className="text-xs text-muted-foreground">attendances over five days</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="type-numeral text-2xl">{formatNumber(late)}</span>
<span className="text-xs text-muted-foreground">arrived late</span>
</div>
</div>
<StackedBarChart
data={week.days}
index="day"
series={SERIES}
height={220}
valueFormatter={(value) => formatNumber(value)}
aria-label="Attendance by weekday"
/>
</div>
</Widget>
)
}/**
* 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