Skip to contentVibraUI
Dashboards

Board

The team's board read one way for every task band: who is on a task, the steps it breaks into and how many are done, when it is due in words, and its comments and files.

Server-only, since it reads db: a section imports it from its own data file and hands the rows to its island as props. The tasks are db.tasks — title, column, priority, owner, estimate, window and labels are the entity's own. What the entity does not record is a rule written here once, so a task reads the same on every band: who is on it (the owner first; the project's lead on work of five points or more; for a task in review, its reviewer — the next active member on the list after the owner who is not on it already); its steps, named after the verb its title opens on (a migration is written, dry-run, backfilled, switched over and cleaned up) and more of them the bigger the estimate — all done on a finished task, all but the last in review, the share of its window that has run in progress, none before it starts; and its comments and files, counted once from seeded("board:<id>") inside its column's range (TASK_ACTIVITY), busier the further it has got. Due is said in whole UTC days from REFERENCE_DATE: a task due today is due today, not late, and it is late once its day has passed unfinished — "Overdue by 3 days", in words, so a band never says it only in red. The star on a task is the to-do list's label and is dropped from the labels a card shows. A task's page is /projects/tasks?task=<id>: the query is a convention the tasks page has to read (the kit's Projects tasks page shows every task and does not read it yet), so until yours does a card lands on the whole list; point taskHref at your own route and every card follows.

Install

npx shadcn@latest add @vibra/board-projects

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

Props

PropTypeDefaultDescription
boardTasks() => BoardTask[]—Every task on the board, soonest due first.
boardTask(task: Task) => BoardTask—One db.tasks row as every band reads it.
BoardTask{ id, title, status, statusLabel, priority, points, project, assignees, due, subtasks, progress, labels, comments, attachments, href }—A task as a card shows it: the entity's own fields, who is on it, when it is due, its steps and the share of them done, its labels without the star, and its talk and files.
dueOf(task) => Due—When a task is due: { iso, label, days, words, overdue }, in whole UTC days from REFERENCE_DATE.
subtasksOf(task) => Subtask[]—The steps a task breaks into, and which of them are done.
TASK_STATUS_LABELSRecord<TaskStatus, string>—The words each column is called by: Backlog, To do, In progress, In review, Done.
TASK_ACTIVITYRecord<TaskStatus, { comments: [min, max]; attachments: [min, max] }>—How much talk and how many files a task gathers in each column, busier the further it has got.
taskHref(id: string) => string—A task's page: /projects/tasks?task=<id>, a query your tasks page reads.
TASKS_HREFstring"/projects/tasks"The Projects dashboard's tasks page.

Dependencies

Source

lib/dashboards/projects/board.ts
/**
 * The team's board read one way for every task band: who is on a task, the
 * steps it breaks into and how many are done, when it is due in words, and
 * how much talk and how many files it has gathered. The tasks are
 * `db.tasks` — title, column, priority, owner, estimate, window and labels
 * are the entity's own — and what the entity does not record is a rule over
 * what it does, written down here once so a task reads the same on every
 * band that shows it:
 *
 * - **Who is on it**: the owner first; then the project's lead, on work of
 *   five points or more; then, for a task in review, its reviewer — the next
 *   active member on the list after the owner who is not on it already.
 * - **Its steps** follow from what the work is — the verb its title opens
 *   on — and there are more of them the bigger the estimate. A finished task
 *   has done them all, one in review all but the last, one in progress the
 *   share its window has run, and one not started none.
 * - **Comments and files** are counted once from `seeded("board:<id>")`,
 *   busier the further a task has got.
 * - **Due** is said in whole UTC days from `REFERENCE_DATE`: a task due today
 *   is due today, not late; it is late once its day has passed unfinished.
 *
 * Server-only, because it reads `db`: a section imports it from its own
 * `<name>.data.ts` and hands the rows to its island as props.
 */
import { avatarFor } from "@/lib/avatars"
import { REFERENCE_DATE, db, intBetween, seeded, type Task } from "@/lib/sample-data"

/** The Projects dashboard's tasks page. */
export const TASKS_HREF = "/projects/tasks"

/** A task's page: the tasks view, opened on it. */
export function taskHref(id: string): string {
  return `${TASKS_HREF}?task=${id}`
}

export type TaskStatus = Task["status"]
export type TaskPriority = Task["priority"]

/** The words each column is called by. */
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
  backlog: "Backlog",
  todo: "To do",
  in_progress: "In progress",
  review: "In review",
  done: "Done",
}

export type BoardPerson = { id: string; name: string; avatarUrl: string }
export type Subtask = { id: string; title: string; done: boolean }

/** When a task is due: the day in UTC, how far off in whole days, and that said in words. */
export type Due = {
  iso: string
  /** "Sep 7". */
  label: string
  /** Whole UTC days from today; negative when the day has passed. */
  days: number
  /** "Due today", "Due in 5 days", "Overdue by 3 days", "Done". */
  words: string
  overdue: boolean
}

export type BoardTask = {
  id: string
  title: string
  status: TaskStatus
  statusLabel: string
  priority: TaskPriority
  points: number
  project: { id: string; code: string; name: string }
  /** The owner first. */
  assignees: BoardPerson[]
  due: Due
  subtasks: Subtask[]
  /** Subtasks done as a share of all of them, 0..100. */
  progress: number
  labels: string[]
  comments: number
  attachments: number
  href: string
}

const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** How many steps a task of each estimate breaks into. */
const STEP_COUNT: Record<number, number> = { 1: 2, 2: 3, 3: 3, 5: 4, 8: 5 }

/** The steps of each kind of work, keyed by the verb a title opens on. */
const STEPS: Record<string, readonly string[]> = {
  Add: ["Sketch the change", "Build it behind a flag", "Write the tests", "Ship to staging", "Turn the flag on"],
  Audit: ["List what it touches", "Read the access paths", "Write up the findings", "File the fixes", "Sign off the audit"],
  Backfill: ["Write the backfill script", "Dry-run on a copy", "Run it in batches", "Check the counts", "Remove the script"],
  Cache: ["Measure the hot paths", "Add the cache layer", "Set the expiry rules", "Load-test it", "Watch the hit rate"],
  Document: ["Outline the page", "Write the first draft", "Add the examples", "Get a review", "Publish it"],
  Harden: ["Map the failure modes", "Add timeouts and retries", "Validate the inputs", "Add alerts", "Run a failure drill"],
  Instrument: ["Pick the signals", "Add the metrics", "Add the traces", "Build the dashboard", "Set the alerts"],
  Migrate: ["Write the migration", "Dry-run on staging", "Backfill the old rows", "Switch reads over", "Remove the old path"],
  "Rate-limit": ["Pick the limits", "Add the limiter", "Return a clear 429", "Load-test it", "Document the limits"],
  Refactor: ["Pin the behaviour with tests", "Split the module", "Move the callers", "Delete the dead code", "Check the timings"],
  Retire: ["Find every caller", "Announce the date", "Move the callers off", "Switch it off", "Delete the code"],
  Split: ["Draw the boundary", "Extract the first part", "Move the traffic", "Extract the rest", "Remove the old path"],
  Throttle: ["Measure the peaks", "Add the throttle", "Queue the overflow", "Load-test it", "Tune the limits"],
  Version: ["Freeze the current shape", "Add the new version", "Route by version", "Tell the callers", "Mark the old one deprecated"],
}

const FALLBACK_STEPS = ["Scope the work", "Build it", "Test it", "Review it", "Ship it"]

/** How much talk and how many files a task gathers by the column it is in: [min, max] of each, busier the further it has got. */
export const TASK_ACTIVITY: Record<TaskStatus, { comments: [number, number]; attachments: [number, number] }> = {
  backlog: { comments: [0, 1], attachments: [0, 1] },
  todo: { comments: [0, 2], attachments: [0, 2] },
  in_progress: { comments: [1, 5], attachments: [0, 3] },
  review: { comments: [2, 8], attachments: [1, 4] },
  done: { comments: [1, 6], attachments: [1, 4] },
}

const utcDay = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())

/** When a task is due, said from `REFERENCE_DATE` in whole UTC days. */
export function dueOf(task: Pick<Task, "dueAt" | "status">): Due {
  const days = Math.round((utcDay(task.dueAt) - utcDay(REFERENCE_DATE)) / DAY_MS)
  const done = task.status === "done"
  const plural = (n: number) => `${n} ${n === 1 ? "day" : "days"}`
  const words = done
    ? "Done"
    : days < 0
      ? `Overdue by ${plural(-days)}`
      : days === 0
        ? "Due today"
        : days === 1
          ? "Due tomorrow"
          : `Due in ${plural(days)}`
  return { iso: task.dueAt.toISOString(), label: DAY.format(task.dueAt), days, words, overdue: !done && days < 0 }
}

/** The steps a task breaks into, and which of them are done, by the rules above. */
export function subtasksOf(task: Pick<Task, "id" | "title" | "points" | "status" | "startAt" | "dueAt">): Subtask[] {
  const verb = task.title.split(" ")[0]
  const count = STEP_COUNT[task.points] ?? 3
  const steps = (STEPS[verb] ?? FALLBACK_STEPS).slice(0, count)
  let done = 0
  if (task.status === "done") done = count
  else if (task.status === "review") done = count - 1
  else if (task.status === "in_progress") {
    const span = task.dueAt.getTime() - task.startAt.getTime()
    const ran = span <= 0 ? 1 : (REFERENCE_DATE.getTime() - task.startAt.getTime()) / span
    done = Math.min(count - 1, Math.max(1, Math.floor(ran * count)))
  }
  return steps.map((title, index) => ({ id: `${task.id}-s${index + 1}`, title, done: index < done }))
}

function peopleOn(task: Task): BoardPerson[] {
  const members = db.members.all()
  const byId = new Map(members.map((member) => [member.id, member]))
  const project = db.projects.all().find((row) => row.id === task.projectId)
  const ids = [task.assignee]
  if (task.points >= 5 && project && !ids.includes(project.lead)) ids.push(project.lead)
  if (task.status === "review") {
    const active = members.filter((member) => member.status === "active")
    const at = active.findIndex((member) => member.id === task.assignee)
    const reviewer = [...active.slice(at + 1), ...active.slice(0, at)].find((member) => !ids.includes(member.id))
    if (reviewer) ids.push(reviewer.id)
  }
  return ids.flatMap((id) => {
    const member = byId.get(id)
    return member ? [{ id, name: member.name, avatarUrl: avatarFor(member.name) }] : []
  })
}

/** One task as every band reads it. */
export function boardTask(task: Task): BoardTask {
  const project = db.projects.all().find((row) => row.id === task.projectId)
  const subtasks = subtasksOf(task)
  const rand = seeded(`board:${task.id}`)
  const activity = TASK_ACTIVITY[task.status]
  return {
    id: task.id,
    title: task.title,
    status: task.status,
    statusLabel: TASK_STATUS_LABELS[task.status],
    priority: task.priority,
    points: task.points,
    project: { id: task.projectId, code: project?.code ?? "—", name: project?.name ?? "No project" },
    assignees: peopleOn(task),
    due: dueOf(task),
    subtasks,
    progress: Math.round((subtasks.filter((step) => step.done).length / subtasks.length) * 100),
    labels: task.labels.filter((label) => label !== "starred"),
    comments: intBetween(rand, ...activity.comments),
    attachments: intBetween(rand, ...activity.attachments),
    href: taskHref(task.id),
  }
}

/** Every task on the board, soonest due first; a tie goes to the id. */
export function boardTasks(): BoardTask[] {
  return [...db.tasks.all()]
    .sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.id.localeCompare(b.id))
    .map(boardTask)
}