Skip to contentVibraUI

Team

How many people are on the team, the six with the most on their plate, and the mean cycle time, throughput and review time; reads team().

Preview

Install

npx shadcn@latest add @vibra/widget-projects-overview-team-card

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

Source

app/projects/components/team-card.tsx
import { 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>
  )
}
app/projects/data.ts
/**
 * 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 }
}
app/projects/plan.ts
/**
 * 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
}
app/projects/history.ts
/**
 * 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