/projectsProjects dashboard
A project office: what is in flight and on track, hours logged and overdue tasks, points planned against done at three ranges, the team and its rates, the week's deadlines, delivery by quarter, effort by column, and every project in a table that opens the roadmap or archives one.
Installs at /projects, with the roadmap kept at /projects/timeline under it; the page reads db.projects, db.tasks and db.members on the server. plan.ts holds the plan: a task's points are expected evenly between its startAt and dueAt, and it earns them in full when done, at half or eight tenths of its elapsed window in progress or in review, and not at all while it waits — so a project is on track when its earned value covers nine tenths of its plan, and an overdue task in flight counts against it. Hours are points at two hours each, credited by the same elapsed share this month. history.ts holds fifty-two weeks of planned and done points — measured where the task book reaches, drawn once from seeded("projects-overview") at the book's pace before that — which the range select re-buckets on the client and the quarter bars sum, so the two charts agree at the join. Open timeline is a real link so a framed preview can follow it; Archive calls archiveProject, which refuses a project with open tasks, naming how many. Composes AppShell, PageHeader, DashboardGrid, StatCardGroup, StatCard, AreaChart, DonutChart, HorizontalBarChart, Select, Widget, Card, PriorityBadge, StatusBadge, Badge, AvatarGroup, DescriptionList, Progress, DataTable, DropdownMenu, UserCell, Button and Callout.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { DeliveredByQuarter } from "./components/delivered-by-quarter"
import { DeliveryChart } from "./components/delivery-chart"
import { DueThisWeek } from "./components/due-this-week"
import { EffortByStatus } from "./components/effort-by-status"
import { ProjectStats } from "./components/project-stats"
import { ProjectsTable } from "./components/projects-table"
import { TeamCard } from "./components/team-card"
import {
currentUser,
deliveredByQuarter,
deliveryByRange,
dueThisWeek,
effortByStatus,
lastUpdated,
projectRows,
projectStats,
shellNotifications,
team,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The projects page. A server component: it reads the projects, the task
* book and the team through `db`, works every rate out, and hands plain rows
* to the islands that hold state — the delivery chart's range and the table.
*/
export default function ProjectsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Projects"
description="What is in flight, what is due, and how the work moves."
meta={lastUpdated()}
/>
<ProjectStats stats={projectStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<DeliveryChart series={deliveryByRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<TeamCard team={team()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<DueThisWeek tasks={dueThisWeek()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<DeliveredByQuarter quarters={deliveredByQuarter()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<EffortByStatus slices={effortByStatus()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<ProjectsTable rows={projectRows()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/projects-overviewNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { DeliveredByQuarter } from "./components/delivered-by-quarter"
import { DeliveryChart } from "./components/delivery-chart"
import { DueThisWeek } from "./components/due-this-week"
import { EffortByStatus } from "./components/effort-by-status"
import { ProjectStats } from "./components/project-stats"
import { ProjectsTable } from "./components/projects-table"
import { TeamCard } from "./components/team-card"
import {
currentUser,
deliveredByQuarter,
deliveryByRange,
dueThisWeek,
effortByStatus,
lastUpdated,
projectRows,
projectStats,
shellNotifications,
team,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The projects page. A server component: it reads the projects, the task
* book and the team through `db`, works every rate out, and hands plain rows
* to the islands that hold state — the delivery chart's range and the table.
*/
export default function ProjectsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Projects"
description="What is in flight, what is due, and how the work moves."
meta={lastUpdated()}
/>
<ProjectStats stats={projectStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<DeliveryChart series={deliveryByRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<TeamCard team={team()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<DueThisWeek tasks={dueThisWeek()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<DeliveredByQuarter quarters={deliveredByQuarter()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<EffortByStatus slices={effortByStatus()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<ProjectsTable rows={projectRows()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* 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),
}
})
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"
/**
* The two things this page changes. Server actions, so the page can stay a
* server component and still hand its client islands something to call, and
* `Result` so every caller reads one success-or-error shape.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/**
* Takes a project off the board. Only a project with nothing left open can
* go: archiving one with open tasks would strand them, so the count of open
* tasks — read off the book, never the form — is what the refusal names.
*/
export async function archiveProject(id: string): Promise<Result<{ id: string; name: string }>> {
const project = await db.projects.get(id)
if (!project) {
return { ok: false, error: { code: "not_found", message: `No project with id "${id}".` } }
}
const open = db.tasks.all().filter((task) => task.projectId === id && task.status !== "done").length
if (open > 0) {
return {
ok: false,
error: {
code: "conflict",
message: `${project.name} still has ${open} open task${open === 1 ? "" : "s"}; finish or move them before archiving it.`,
},
}
}
const removed = await db.projects.remove(id)
if (!removed.ok) return removed
return { ok: true, data: { id, name: project.name } }
}"use client"
import { formatNumber } from "@/lib/format"
import { HorizontalBarChart } from "@/components/ui/horizontal-bar-chart"
import { Widget } from "@/components/ui/widget"
import { type QuarterDelivered } from "../data"
const SERIES = [{ key: "delivered", label: "Delivered" }]
export type DeliveredByQuarterProps = { quarters: QuarterDelivered[] }
/** Points delivered in each of the last four quarters, as sideways bars. */
export function DeliveredByQuarter({ quarters }: DeliveredByQuarterProps) {
return (
<Widget
data-widget="widget-projects-overview-delivered-by-quarter"
title="Delivered by quarter"
description="Points finished, four quarters back"
className="h-full"
>
<HorizontalBarChart
data={quarters}
index="quarter"
series={SERIES}
height={200}
showLegend={false}
valueFormatter={(value) => formatNumber(value)}
aria-label="Points delivered by quarter"
/>
</Widget>
)
}"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Widget } from "@/components/ui/widget"
import { type DeliveryPoint, type RangeKey } from "../data"
import { RANGE_OPTIONS } from "./projects-vocabulary"
const SERIES = [
{ key: "planned", label: "Planned" },
{ key: "done", label: "Done" },
]
const START = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export type DeliveryChartProps = { series: Record<RangeKey, DeliveryPoint[]> }
/**
* Points planned against points done over the range the select names. The
* range is client state; the three series arrive computed from the server
* page, so switching is a re-render rather than a round trip.
*/
export function DeliveryChart({ series }: DeliveryChartProps) {
const [range, setRange] = React.useState<RangeKey>("3m")
const option = RANGE_OPTIONS.find((entry) => entry.value === range) ?? RANGE_OPTIONS[0]
return (
<Widget
data-widget="widget-projects-overview-delivery-chart"
title="Delivery"
description={`Points planned and done, by ${option.unit}`}
className="h-full"
actions={
<Select
value={range}
onValueChange={(next) => setRange((next as RangeKey | null) ?? "3m")}
items={RANGE_OPTIONS.map(({ value, label }) => ({ value, label }))}
>
<SelectTrigger size="sm" aria-label="Range" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGE_OPTIONS.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
>
<AreaChart
data={series[range]}
index="start"
series={SERIES}
height={240}
valueFormatter={(value) => formatNumber(value)}
indexFormatter={(value) => START.format(new Date(String(value)))}
aria-label={`Delivery by ${option.unit}`}
/>
</Widget>
)
}import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { Widget } from "@/components/ui/widget"
import { type DueTask } from "../data"
const DUE = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export type DueThisWeekProps = { tasks: DueTask[] }
/**
* The three tasks due soonest, each on a card of its own, and a link to the
* board for everything after them. The link is a real `next/link`, so a
* framed preview can rewrite it to the board's own preview.
*/
export function DueThisWeek({ tasks }: DueThisWeekProps) {
return (
<Widget
data-widget="widget-projects-overview-due-this-week"
title="Due this week"
description="The three closest deadlines"
actions={
<Link href="/projects/tasks" className={buttonVariants({ variant: "ghost", size: "sm" })}>
Show all
<ArrowRightIcon />
</Link>
}
>
{tasks.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Nothing due this week.</p>
) : (
<ul data-slot="due-tasks" className="grid gap-3 sm:grid-cols-3">
{tasks.map((task) => (
<li key={task.id} className="flex">
<Card size="sm" className="w-full">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<PriorityBadge priority={task.priority} size="sm" />
<span className="text-xs tabular-nums text-muted-foreground">{DUE.format(task.dueAt)}</span>
</div>
<p className="text-sm font-medium">{task.title}</p>
<Badge variant="outline" className="w-fit">
<span className="font-mono text-2xs">{task.code}</span>
{task.project}
</Badge>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</Widget>
)
}"use client"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { DonutChart } from "@/components/ui/donut-chart"
import { CHART_BG, CHART_TOKENS } from "@/components/ui/percentage-bar"
import { Widget } from "@/components/ui/widget"
import { type EffortSlice } from "../data"
import { TASK_STATUS_LABELS } from "./projects-vocabulary"
export type EffortByStatusProps = { slices: EffortSlice[] }
/**
* Points on the board by column: a ring, and under it one count per column
* in the palette's order, so the legend is the ring's own reading.
*/
export function EffortByStatus({ slices }: EffortByStatusProps) {
const total = slices.reduce((sum, slice) => sum + slice.points, 0)
return (
<Widget
data-widget="widget-projects-overview-effort-by-status"
title="Effort by status"
description="Points on the board, by column"
className="h-full"
>
<DonutChart
data={slices.map((slice) => ({ name: TASK_STATUS_LABELS[slice.status], value: slice.points }))}
height={200}
innerRadius={60}
centerLabel="Points"
centerValue={formatNumber(total)}
valueFormatter={(value) => formatNumber(value)}
showLegend={false}
/>
<ul data-slot="effort-counts" aria-label="Points by status" className="mt-3 grid grid-cols-5 gap-1">
{slices.map((slice, index) => (
<li key={slice.status} className="flex min-w-0 flex-col items-center gap-0.5 text-center">
<span
aria-hidden="true"
className={cn("size-2 rounded-full", CHART_BG[CHART_TOKENS[index % CHART_TOKENS.length]])}
/>
<span className="truncate text-xs text-muted-foreground">{TASK_STATUS_LABELS[slice.status]}</span>
<span className="text-base font-semibold tabular-nums">{formatNumber(slice.points)}</span>
</li>
))}
</ul>
</Widget>
)
}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>
)
}"use client"
import * as React from "react"
import Link from "next/link"
import { ArchiveIcon, ArrowRightIcon, EllipsisIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DataTable, DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { archiveProject } from "../actions"
import { type ProjectRow } from "../data"
import { PROJECT_STATUS_LABELS, PROJECT_STATUS_MAP } from "./projects-vocabulary"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/**
* The row's menu. Hand-rolled rather than `DataTableRowActions`, because one
* of its two entries is a link: "Open timeline" has to be a real anchor so a
* framed preview can rewrite it to the roadmap's own preview, which a
* `router.push` would walk straight out of.
*/
function RowMenu({ row, onArchive }: { row: ProjectRow; onArchive: (row: ProjectRow) => void }) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open menu for ${row.name}`}
className="ml-auto text-muted-foreground"
>
<EllipsisIcon aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto min-w-40">
<DropdownMenuItem render={<Link href="/projects/timeline" />}>
<ArrowRightIcon aria-hidden="true" />
Open timeline
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onArchive(row)}>
<ArchiveIcon aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export type ProjectsTableProps = { rows: ProjectRow[] }
/**
* Every project: who runs it, its window, where it stands and how far along
* it is. Archive writes through the server — only a project with nothing
* open can go — and the row leaves with the name in a status line; a refusal
* names the open tasks in an alert above the table.
*/
export function ProjectsTable({ rows }: ProjectsTableProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [projects, setProjects] = React.useState(rows)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const archive = React.useCallback(async (row: ProjectRow) => {
setRefusal("")
setNotice("")
const result = await archiveProject(row.id)
if (!result.ok) return setRefusal(result.error.message)
setProjects((current) => current.filter((project) => project.id !== row.id))
setNotice(`Archived ${result.data.name}.`)
}, [])
const columns = React.useMemo<DataTableColumnDef<ProjectRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Project" />,
cell: ({ row }) => (
<span className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{row.original.code}</span>
<span className="font-medium">{row.original.name}</span>
</span>
),
meta: { label: "Project" },
},
{
id: "lead",
accessorFn: (row) => row.lead.name,
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.lead.name} email={row.original.lead.email} src={row.original.lead.avatarUrl} />,
meta: { label: "Owner" },
},
{
accessorKey: "startAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Start" />,
cell: ({ row }) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">{DAY.format(row.original.startAt)}</span>
),
meta: { label: "Start" },
},
{
accessorKey: "endAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Deadline" />,
cell: ({ row }) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">{DAY.format(row.original.endAt)}</span>
),
meta: { label: "Deadline" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge
status={row.original.status}
label={PROJECT_STATUS_LABELS[row.original.status]}
map={PROJECT_STATUS_MAP}
/>
),
meta: { label: "Status" },
},
{
accessorKey: "progress",
header: ({ column }) => <DataTableColumnHeader column={column} title="Progress" />,
cell: ({ row }) => (
<span className="flex w-32 items-center gap-2">
<Progress
value={row.original.progress}
aria-label={`${row.original.name} progress`}
className="min-w-0 flex-1"
/>
<span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{`${row.original.progress}%`}
</span>
</span>
),
meta: { label: "Progress" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => <RowMenu row={row.original} onArchive={(project) => void archive(project)} />,
},
],
[archive]
)
return (
<Widget
titleId={titleId}
data-widget="widget-projects-overview-projects-table"
title="Projects"
description="Every project on the books"
>
<div className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={projects}
pageSize={12}
searchKey="name"
searchPlaceholder="Search projects…"
enableRowSelection={false}
getRowId={(project) => project.id}
initialSorting={[{ id: "name", desc: false }]}
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</div>
</Widget>
)
}import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words this page puts on a project's status and a task's column, and the
* ranges the delivery chart offers; a task's priority is the kit's own
* PriorityBadge. Vocabulary, not data —
* it lives beside the islands that print it rather than in `data.ts`, which
* reads `db` and must never reach the browser.
*/
export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: "Planning",
active: "Active",
at_risk: "At risk",
shipped: "Shipped",
}
export const PROJECT_STATUS_MAP: Record<string, StatusVariant> = {
planning: "info",
active: "success",
at_risk: "warning",
shipped: "primary",
}
export const TASK_STATUS_LABELS: Record<string, string> = {
backlog: "Backlog",
todo: "To do",
in_progress: "In progress",
review: "In review",
done: "Done",
}
export const RANGE_OPTIONS = [
{ value: "3m", label: "3 months", unit: "week" },
{ value: "6m", label: "6 months", unit: "fortnight" },
{ value: "12m", label: "12 months", unit: "four weeks" },
] as constimport { formatNumber } from "@/lib/format"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { DescriptionList } from "@/components/ui/description-list"
import { Widget } from "@/components/ui/widget"
import { type TeamSummary } from "../data"
export type TeamCardProps = { team: TeamSummary }
/**
* Who is on the team, the six with the most on their plate, and how the work
* moves: the three rates are means over the task book, worked out on the
* server, so this card holds no state and renders there.
*/
export function TeamCard({ team }: TeamCardProps) {
return (
<Widget
data-widget="widget-projects-overview-team-card"
title="Team"
description="Who is doing the work, and how fast"
className="h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="type-numeral text-3xl">{formatNumber(team.people)}</span>
<span className="text-xs text-muted-foreground">people on the team</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">Most on their plate</span>
<AvatarGroup users={team.busiest} max={6} size="lg" />
</div>
<DescriptionList
size="sm"
items={[
{ term: "Avg cycle time", description: `${team.cycleDays.toFixed(1)} days` },
{ term: "Throughput", description: `${team.throughput.toFixed(1)} tasks/week` },
{ term: "Review time", description: `${team.reviewDays.toFixed(1)} days` },
]}
/>
</div>
</Widget>
)
}