Projects headline
Active projects and how many are at risk, the share on track, the hours logged this month and the overdue tasks; reads projectStats().
Preview
import { AlertTriangleIcon, ClockIcon, FolderKanbanIcon, TargetIcon } from "lucide-react"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type ProjectStat } from "../data"
// One icon per headline, keyed by the stat rather than its position, so the
// row can be reordered without the icons following the wrong numbers.
const ICONS: Record<ProjectStat["key"], React.ReactNode> = {
active: <FolderKanbanIcon />,
track: <TargetIcon />,
hours: <ClockIcon />,
overdue: <AlertTriangleIcon />,
}
export type ProjectStatsProps = { stats: ProjectStat[] }
/** The four headline numbers. No state, so the tiles render on the server. */
export function ProjectStats({ stats }: ProjectStatsProps) {
return (
<StatCardGroup data-widget="widget-projects-overview-project-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.key}
id={stat.key}
label={stat.label}
value={stat.value}
description={stat.description}
icon={ICONS[stat.key]}
/>
))}
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-projects-overview-project-statsNeeds the @vibra registry in your components.json — set it up once.
Source
import { AlertTriangleIcon, ClockIcon, FolderKanbanIcon, TargetIcon } from "lucide-react"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type ProjectStat } from "../data"
// One icon per headline, keyed by the stat rather than its position, so the
// row can be reordered without the icons following the wrong numbers.
const ICONS: Record<ProjectStat["key"], React.ReactNode> = {
active: <FolderKanbanIcon />,
track: <TargetIcon />,
hours: <ClockIcon />,
overdue: <AlertTriangleIcon />,
}
export type ProjectStatsProps = { stats: ProjectStat[] }
/** The four headline numbers. No state, so the tiles render on the server. */
export function ProjectStats({ stats }: ProjectStatsProps) {
return (
<StatCardGroup data-widget="widget-projects-overview-project-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.key}
id={stat.key}
label={stat.label}
value={stat.value}
description={stat.description}
icon={ICONS[stat.key]}
/>
))}
</StatCardGroup>
)
}/**
* What /projects reads. The projects are `db.projects`, the work is `db.tasks`,
* the people are `db.members`. Every headline number is a rule over those
* rows: `plan.ts` holds the plan — points earned evenly between a task's
* `startAt` and `dueAt`, done in full, in flight by a weighted share, waiting
* nothing — so a project is on track when its earned value covers nine
* tenths of its plan; hours are points at two hours each, credited by the
* same elapsed share; the team's three rates are means over the book.
* `history.ts` holds the delivery history the chart and the quarter bars
* read. "Now" is `REFERENCE_DATE`.
*
* 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, type Member, type Project, type Task } from "@/lib/sample-data"
import { elapsed, onTrack } from "./plan"
export {
delivery,
deliveredByQuarter,
deliveryByRange,
type DeliveryPoint,
type QuarterDelivered,
type RangeKey,
} from "./history"
const DAY = 86_400_000
const WEEK = 7 * DAY
const NOW = REFERENCE_DATE.getTime()
/** 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)
const HOURS_PER_POINT = 2
const tasks = () => db.tasks.all()
const points = (rows: Task[]): number => rows.reduce((sum, task) => sum + task.points, 0)
const mean = (values: number[]): number =>
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
const between = (at: Date, from: number, to: number): boolean => at.getTime() >= from && at.getTime() < to
/** Midnight UTC on the first of the month `offset` months from the current one. */
const monthStart = (offset: number): number =>
Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)
/* -------------------------------------------------------------------------- */
/* Headlines */
/* -------------------------------------------------------------------------- */
/** A project still being worked: active, or active and behind. */
const inFlight = (project: Project): boolean => project.status === "active" || project.status === "at_risk"
/** Whether a project's earned value covers its plan, read at REFERENCE_DATE — `plan.ts` has the rule. */
export function isOnTrack(project: Project): boolean {
return onTrack(
tasks().filter((task) => task.projectId === project.id),
NOW
)
}
export type ProjectStat = {
key: "active" | "track" | "hours" | "overdue"
label: string
value: string
description: string
}
/** The four headline numbers. */
export function projectStats(): ProjectStat[] {
const active = db.projects.all().filter(inFlight)
const tracking = active.filter(isOnTrack).length
const rows = tasks()
// Hours at two a point: a task finished this month in full, a task in
// flight by the share of its window that fell inside the month — the same
// elapsed share the plan reads it by.
const hours = Math.round(
rows.reduce((sum, task) => {
if (task.status === "done") return between(task.updatedAt, monthStart(0), NOW) ? sum + task.points : sum
if (task.status === "in_progress" || task.status === "review")
return sum + task.points * (elapsed(task, NOW) - elapsed(task, monthStart(0)))
return sum
}, 0) * HOURS_PER_POINT
)
const open = rows.filter((task) => task.status !== "done")
const overdue = open.filter((task) => task.dueAt.getTime() < NOW)
return [
{
key: "active",
label: "Active projects",
value: formatNumber(active.length),
description: `${active.filter((project) => project.status === "at_risk").length} at risk`,
},
{
key: "track",
label: "On track",
value: formatPercent(active.length === 0 ? 0 : tracking / active.length, { maximumFractionDigits: 0 }),
description: `${tracking} of ${active.length} in flight`,
},
{
key: "hours",
label: "Hours logged",
value: `${formatNumber(hours)}h`,
description: `month to date · ${HOURS_PER_POINT}h a point`,
},
{
key: "overdue",
label: "Overdue tasks",
value: formatNumber(overdue.length),
description: `of ${open.length} open`,
},
]
}
/* -------------------------------------------------------------------------- */
/* The team */
/* -------------------------------------------------------------------------- */
export type TeamSummary = {
people: number
/** The six with the most tasks on their plate. */
busiest: { name: string; src?: string }[]
/** Mean days from a done task's start to the day it was finished. */
cycleDays: number
/** Done tasks per week over the last four weeks. */
throughput: number
/** Mean days the tasks in review have been waiting. */
reviewDays: number
}
/** Who is on the team, and how the work moves through them. */
export function team(): TeamSummary {
const members = db.members.all().filter((member) => member.status === "active")
const rows = tasks()
const load = new Map<string, number>()
for (const task of rows) load.set(task.assignee, (load.get(task.assignee) ?? 0) + 1)
const byId = new Map(members.map((member) => [member.id, member]))
const busiest = [...load.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([id]) => byId.get(id))
.filter((member): member is Member => member !== undefined)
.slice(0, 6)
.map((member) => ({ name: member.name, src: member.avatarUrl }))
const done = rows.filter((task) => task.status === "done")
const reviewing = rows.filter((task) => task.status === "review")
return {
people: members.length,
busiest,
cycleDays: mean(done.map((task) => (task.updatedAt.getTime() - task.startAt.getTime()) / DAY)),
throughput: done.filter((task) => task.updatedAt.getTime() >= NOW - 4 * WEEK).length / 4,
reviewDays: mean(reviewing.map((task) => (NOW - task.updatedAt.getTime()) / DAY)),
}
}
/* -------------------------------------------------------------------------- */
/* Due this week, effort, projects */
/* -------------------------------------------------------------------------- */
export type DueTask = {
id: string
title: string
priority: Task["priority"]
project: string
code: string
dueAt: Date
}
/** The three open tasks due soonest inside the coming week. */
export function dueThisWeek(): DueTask[] {
const codes = new Map(db.projects.all().map((project) => [project.id, project]))
return tasks()
.filter((task) => task.status !== "done" && task.dueAt.getTime() >= TODAY && task.dueAt.getTime() < TODAY + WEEK)
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.id.localeCompare(b.id))
.slice(0, 3)
.map((task) => ({
id: task.id,
title: task.title,
priority: task.priority,
project: codes.get(task.projectId)?.name ?? "Unassigned",
code: codes.get(task.projectId)?.code ?? "—",
dueAt: task.dueAt,
}))
}
export type EffortSlice = { status: Task["status"]; points: number }
const STATUSES: Task["status"][] = ["backlog", "todo", "in_progress", "review", "done"]
/** Points on the board by column, in the board's own order. */
export function effortByStatus(): EffortSlice[] {
const rows = tasks()
return STATUSES.map((status) => ({ status, points: points(rows.filter((task) => task.status === status)) }))
}
/** One row of the projects table. */
export type ProjectRow = {
id: string
name: string
code: string
lead: { name: string; email: string; avatarUrl?: string }
startAt: Date
endAt: Date
status: Project["status"]
progress: number
openTasks: number
}
/** Every project on the books; the table sorts and pages them itself. */
export function projectRows(): ProjectRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
const rows = tasks()
return db.projects.all().map((project) => {
const lead = members.get(project.lead)
return {
id: project.id,
name: project.name,
code: project.code,
lead: { name: lead?.name ?? "Unassigned", email: lead?.email ?? "", avatarUrl: lead?.avatarUrl },
startAt: project.startAt,
endAt: project.endAt,
status: project.status,
progress: project.progress,
openTasks: rows.filter((task) => task.projectId === project.id && task.status !== "done").length,
}
})
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
/** The line under the title: the month, and when the numbers were read. */
export function lastUpdated(): string {
return `${MONTH.format(REFERENCE_DATE)} · 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 }
}/**
* The plan, as rules over a task. A task's points are expected to be earned
* evenly between its `startAt` and its `dueAt`, so at any instant the plan
* says how many of them should be done; what a task has actually earned is
* its points when it is done, a weighted share of its elapsed window while
* it is in flight, and nothing while it waits. A project is on track when
* what its tasks have earned covers nine tenths of what the plan asked — so
* an overdue task in flight, elapsed in full and earned in part, counts
* against it. Pure functions: no `db`, no clock.
*/
import { type Task } from "@/lib/sample-data"
/** How much of its plan a task in flight is credited with, by column. */
const IN_FLIGHT_CREDIT: Partial<Record<Task["status"], number>> = { in_progress: 0.5, review: 0.8 }
/** The share of the plan a project's earned value has to cover to read on track. */
export const ON_TRACK_RATIO = 0.9
const clamp = (value: number): number => Math.min(1, Math.max(0, value))
/** The share of a task's window that has gone by at `at`: 0 before it starts, 1 once it is due. */
export function elapsed(task: Task, at: number): number {
const span = task.dueAt.getTime() - task.startAt.getTime()
return span <= 0 ? (at >= task.dueAt.getTime() ? 1 : 0) : clamp((at - task.startAt.getTime()) / span)
}
/** The points the plan expected a task to have earned by `at`. */
export function planned(task: Task, at: number): number {
return task.points * elapsed(task, at)
}
/** The points a task has earned by `at`: done in full, in flight by a weighted share of its window, waiting nothing. */
export function earned(task: Task, at: number): number {
if (task.status === "done") return task.points
const credit = IN_FLIGHT_CREDIT[task.status]
return credit === undefined ? 0 : credit * planned(task, at)
}
/** Whether a set of tasks has earned at least `ON_TRACK_RATIO` of what their plan asked by `at`. A plan of nothing is met. */
export function onTrack(tasks: Task[], at: number): boolean {
const asked = tasks.reduce((sum, task) => sum + planned(task, at), 0)
if (asked === 0) return true
return tasks.reduce((sum, task) => sum + earned(task, at), 0) / asked >= ON_TRACK_RATIO
}/**
* The delivery history. The task book runs back about four months, and the
* chart and the quarter bars reach a year, so every week the book covers is
* measured off it and the weeks before its first row are drawn once from
* `seeded("dashboard-projects")` at the book's own pace — a third either way
* of noise — so the history reads as one line rather than a wall of zeros
* followed by the four months that were recorded. The quarters are sums of
* the weeks that start inside them, so the two charts agree at the join.
* Server-only: this module reads `db`; only `data.ts` imports it, and the
* islands take the computed series as props.
*/
import { REFERENCE_DATE, db, seeded, type Task } from "@/lib/sample-data"
import { planned } from "./plan"
const DAY = 86_400_000
const WEEK = 7 * DAY
/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/** Monday of the week today falls in. */
const MONDAY = utcDay(REFERENCE_DATE) - ((new Date(utcDay(REFERENCE_DATE)).getUTCDay() + 6) % 7) * DAY
/** How many weeks the history holds: the longest range the chart offers. */
const HISTORY_WEEKS = 52
/** How many points every range plots, whatever its bucket. */
const POINTS = 13
const points = (rows: Task[]): number => rows.reduce((sum, task) => sum + task.points, 0)
const mean = (values: number[]): number =>
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
const between = (at: Date, from: number, to: number): boolean => at.getTime() >= from && at.getTime() < to
/** Midnight UTC on the first day of the quarter `offset` quarters from the current one. */
const quarterStart = (offset: number): number =>
Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + offset * 3, 1)
const quarterLabel = (start: number): string => {
const at = new Date(start)
return `Q${Math.floor(at.getUTCMonth() / 3) + 1} ${at.getUTCFullYear()}`
}
type WeekPoint = { start: number; planned: number; done: number }
/** The points the plan expected inside one week, and the points finished in it, straight off the book. */
function measure(from: number, to: number): { planned: number; done: number } {
const rows = db.tasks.all()
return {
planned: Math.round(rows.reduce((sum, task) => sum + planned(task, to) - planned(task, from), 0)),
done: points(rows.filter((task) => task.status === "done" && between(task.updatedAt, from, to))),
}
}
/** Fifty-two weeks of planned and done points, oldest first. */
function generate(): WeekPoint[] {
const rand = seeded("dashboard-projects")
const first = Math.min(...db.tasks.all().map((task) => task.createdAt.getTime()))
const starts = Array.from({ length: HISTORY_WEEKS }, (_, index) => MONDAY - (HISTORY_WEEKS - 1 - index) * WEEK)
// The book's own pace: the complete weeks it covers.
const covered = starts.filter((start) => start + WEEK > first && start <= MONDAY - WEEK)
const pace = {
planned: mean(covered.map((start) => measure(start, start + WEEK).planned)),
done: mean(covered.map((start) => measure(start, start + WEEK).done)),
}
const drawn = (value: number) => Math.round(value * (0.65 + rand() * 0.7))
return starts.map((start) =>
start + WEEK > first
? { start, ...measure(start, start + WEEK) }
: { start, planned: drawn(pace.planned), done: drawn(pace.done) }
)
}
// Measured per call rather than held: a task finished since the server
// started is in the next render's weeks. The draws before the book begins are
// a fixed number in a fixed order, so they read the same every time.
export type RangeKey = "3m" | "6m" | "12m"
// How many weeks each range covers, and how many go into one plotted point.
// Not exported: the island that draws the chart names the ranges in its own
// vocabulary, and a runtime import from here would carry `db` into the browser.
const RANGES: Record<RangeKey, { weeks: number; bucket: number }> = {
"3m": { weeks: 13, bucket: 1 },
"6m": { weeks: 26, bucket: 2 },
"12m": { weeks: 52, bucket: 4 },
}
export type DeliveryPoint = { start: string; planned: number; done: number }
/** The range's weeks folded into thirteen points, oldest first. */
export function delivery(range: RangeKey, history: WeekPoint[] = generate()): DeliveryPoint[] {
const { weeks, bucket } = RANGES[range]
const window = history.slice(history.length - weeks)
return Array.from({ length: POINTS }, (_, index) => {
const slice = window.slice(index * bucket, (index + 1) * bucket)
return {
start: new Date(slice[0].start).toISOString().slice(0, 10),
planned: slice.reduce((sum, week) => sum + week.planned, 0),
done: slice.reduce((sum, week) => sum + week.done, 0),
}
})
}
/** Every range the select offers, computed here: the range is client state, the numbers are not. */
export function deliveryByRange(): Record<RangeKey, DeliveryPoint[]> {
const history = generate()
return { "3m": delivery("3m", history), "6m": delivery("6m", history), "12m": delivery("12m", history) }
}
export type QuarterDelivered = { quarter: string; delivered: number }
/** Points delivered in each of the last four quarters, oldest first: the sum of the weeks that start inside each. */
export function deliveredByQuarter(): QuarterDelivered[] {
const history = generate()
return [-3, -2, -1, 0].map((offset) => {
const from = quarterStart(offset)
const to = quarterStart(offset + 1)
return {
quarter: quarterLabel(from),
delivered: history
.filter((week) => week.start >= from && week.start < to)
.reduce((sum, week) => sum + week.done, 0),
}
})
}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 Projects dashboard page