Skip to contentVibraUI
Part of the Projects dashboardinstalls at /projects/timeline

Project roadmap

Every open project as a row of bars over the calendar, with milestones, a weeks or months zoom and a panel for the bar you pick.

Open the live page

Installs at /projects/timeline. The page reads db.projects and db.tasks on the server and hands the island one array: every open project, its tasks as bars, and the day it is due to land as the one milestone each project carries. A shipped project is left out — a roadmap is what is ahead. How far along a bar reads is a rule over the row rather than a stored number: a finished task is finished however early it was, one still in the backlog has not started however late the calendar is, and anything in flight shows the share of its own window that has gone by. The today line is REFERENCE_DATE, not the machine clock, so the picture cannot drift away from the numbers beside it as the year turns. Picking a bar is the only state on the page: the panel beside the chart prints what a position can only imply — who has it, which column it is in, and the days at either end.

Preview

Install

npx shadcn@latest add @vibra/projects-timeline

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

Source

app/projects/timeline/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { TimelineView } from "./components/timeline-view"
import {
  currentUser,
  shellNotifications,
  timelineGroups,
  timelineRange,
  timelineSummary,
  today,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"

/**
 * The roadmap. The page is a server component: it reads every project and its
 * tasks through `db` and hands them to one client island, which owns the zoom
 * and which bar is picked.
 */
export default function ProjectsTimelinePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.timeline}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Timeline"
        description="Where every project is against the calendar, and what is left of it."
        meta={timelineSummary()}
      />

      <TimelineView groups={timelineGroups()} range={timelineRange()} today={today()} />
    </AppShell>
  )
}
app/projects/timeline/data.ts
/**
 * What this page reads. Every project, its window and the tasks under it come
 * from `db.projects`, `db.tasks` and `db.members`; nothing here is written
 * out, and "now" is `REFERENCE_DATE`, which is where the today line goes.
 *
 * The shapes below are what a Gantt group and bar need plus the few extra
 * facts the side panel prints, so the island can render both from one array.
 * The island imports only the types: `db` never crosses into the browser.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Member, type Project, type Task } from "@/lib/sample-data"

export type TimelineTask = {
  id: string
  label: string
  start: Date
  end: Date
  progress?: number
  milestone?: boolean
  /** The facts the side panel prints about the bar a reader picked. */
  projectId: string
  projectCode: string
  owner: string
  ownerInitials: string
  statusLabel: string
  points: number
}

export type TimelineGroup = {
  id: string
  label: string
  status: Project["status"]
  lead: string
  progress: number
  tasks: TimelineTask[]
}

const STATUS_LABELS: Record<Task["status"], string> = {
  backlog: "Backlog",
  todo: "To do",
  in_progress: "In progress",
  review: "In review",
  done: "Done",
}

export const PROJECT_STATUS_LABELS: Record<Project["status"], string> = {
  planning: "Planning",
  active: "Active",
  at_risk: "At risk",
  shipped: "Shipped",
}


// Whichever share of a task's window has gone by, floored at what its column
// already says: a finished task is finished however early it is, and one still
// in the backlog has not started however late the calendar is.
function progressOf(task: Task): number {
  if (task.status === "done") return 100
  if (task.status === "backlog" || task.status === "todo") return 0
  const span = task.dueAt.getTime() - task.startAt.getTime()
  if (span <= 0) return 50
  const gone = (REFERENCE_DATE.getTime() - task.startAt.getTime()) / span
  return Math.round(Math.min(Math.max(gone, 0), 1) * 100)
}

/** Every open project as a row of bars, longest-running first. */
export function timelineGroups(): TimelineGroup[] {
  const tasks = db.tasks.all()
  // Read per call, never held at module scope: a member renamed since the
  // server started is the name on the next render.
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  const nameOf = (id: string) => members.get(id)?.name ?? "Unassigned"

  return db.projects
    .all()
    .filter((project) => project.status !== "shipped")
    .sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
    .map((project) => {
      const own = tasks
        .filter((task) => task.projectId === project.id)
        .sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
        .map(
          (task): TimelineTask => ({
            id: task.id,
            label: task.title,
            start: task.startAt,
            end: task.dueAt,
            progress: progressOf(task),
            projectId: project.id,
            projectCode: project.code,
            owner: nameOf(task.assignee),
            ownerInitials: getInitials(nameOf(task.assignee)),
            statusLabel: STATUS_LABELS[task.status],
            points: task.points,
          })
        )

      // The date the whole thing is due is a moment, not a stretch, so it is
      // the one milestone every project carries.
      const landing: TimelineTask = {
        id: `${project.id}-ship`,
        label: `Ship ${project.code}`,
        start: project.endAt,
        end: project.endAt,
        milestone: true,
        projectId: project.id,
        projectCode: project.code,
        owner: nameOf(project.lead),
        ownerInitials: getInitials(nameOf(project.lead)),
        statusLabel: PROJECT_STATUS_LABELS[project.status],
        points: own.reduce((sum, task) => sum + task.points, 0),
      }

      return {
        id: project.id,
        label: `${project.code} · ${project.name}`,
        status: project.status,
        lead: nameOf(project.lead),
        progress: project.progress,
        tasks: [...own, landing],
      }
    })
}

const DAY = 86_400_000

/** Midnight UTC on the day `date` falls on. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

/**
 * The window the roadmap opens on: three weeks of recent history and the next
 * four months. Left to the outermost dates in the data it would run from a
 * task finished in June to a project landing in February, and a reader would
 * open the page on an empty stretch of last quarter with today somewhere off
 * the right-hand edge. Work outside it is dropped rather than drawn, which is
 * what a roadmap is: what is running and what is ahead.
 */
export function timelineRange(): { start: Date; end: Date } {
  const start = new Date(utcDay(REFERENCE_DATE).getTime() - 21 * DAY)
  const latest = db.projects
    .all()
    .filter((project) => project.status !== "shipped")
    .reduce((furthest, project) => Math.max(furthest, project.endAt.getTime()), start.getTime())
  const cap = utcDay(REFERENCE_DATE).getTime() + 120 * DAY
  return { start, end: new Date(Math.min(latest, cap)) }
}

/** The line under the title: how much is running, and how much of it is late. */
export function timelineSummary(): string {
  const open = db.projects.all().filter((project) => project.status !== "shipped")
  const atRisk = open.filter((project) => project.status === "at_risk").length
  const tasks = db.tasks.all().filter((task) => open.some((project) => project.id === task.projectId))
  return `${open.length} projects · ${tasks.length} tasks · ${atRisk} at risk`
}

/** What the today line is drawn at: the reference instant, not the machine clock. */
export function today(): Date {
  return REFERENCE_DATE
}

/** 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/timeline/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/projects/timeline/components/timeline-view.tsx
"use client"

import * as React from "react"

import { formatDate } from "@/lib/format"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { DescriptionList } from "@/components/ui/description-list"
import { GanttChart } from "@/components/ui/gantt-chart"
import { SectionHeader } from "@/components/ui/section-header"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatusBadge } from "@/components/ui/status-badge"

import { type TimelineGroup, type TimelineTask } from "../data"

const ZOOMS = [
  { value: "weeks", label: "Weeks" },
  { value: "months", label: "Months" },
]

const utc = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })

/** Midnight UTC on the day `date` falls on — the range is a whole-day window. */
const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

export type TimelineViewProps = {
  groups: TimelineGroup[]
  /** The window the chart covers; work outside it is not drawn. */
  range: { start: Date; end: Date }
  /** Where the today line goes — REFERENCE_DATE, the point the rows come from. */
  today: Date
}

/**
 * The roadmap and the panel beside it. Picking a bar is the only state here:
 * the chart reports the task, and the panel prints what the bar's position can
 * only imply — who has it, which column it is in, and the days at either end.
 */
export function TimelineView({ groups, range, today }: TimelineViewProps) {
  const [zoom, setZoom] = React.useState("weeks")
  const tasks = React.useMemo(() => groups.flatMap((group) => group.tasks), [groups])
  // The earliest task overall is often the one most likely to have already
  // ended before the range opens — GanttChart would draw it no bar at all, so
  // the page could open on a side panel naming a task with no pressed bar.
  // Seed from the first task the chart will actually draw instead.
  const [selectedId, setSelectedId] = React.useState(() => {
    const from = utcDay(range.start)
    const to = utcDay(range.end)
    const drawn = tasks.find((task) => utcDay(task.end) >= from && utcDay(task.start) <= to)
    return drawn?.id ?? tasks[0]?.id ?? ""
  })

  const selected: TimelineTask | undefined = tasks.find((task) => task.id === selectedId)
  const group = groups.find((entry) => entry.id === selected?.projectId)

  return (
    <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
      <Card className="min-w-0 flex-1">
        <CardHeader>
          <SectionHeader
            as="h2"
            title="Roadmap"
            description="Every open project, its tasks and the day it is due to land."
          />
          <SegmentedControl
            aria-label="Zoom"
            size="sm"
            options={ZOOMS}
            value={zoom}
            onValueChange={setZoom}
          />
        </CardHeader>
        <CardContent>
          <GanttChart
            aria-label="Project roadmap"
            groups={groups}
            range={range}
            zoom={zoom as "weeks" | "months"}
            today={today}
            selectedId={selectedId}
            onSelect={setSelectedId}
          />
        </CardContent>
      </Card>

      <Card className="w-full shrink-0 lg:max-w-80">
        <CardHeader>
          <SectionHeader
            as="h2"
            title={selected ? selected.label : "Nothing picked"}
            description={group ? group.label : "Pick a bar to read it."}
          />
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          {selected ? (
            <DescriptionList
              items={[
                { term: "Status", description: <StatusBadge status={selected.statusLabel} /> },
                { term: "Owner", description: selected.owner },
                {
                  term: selected.milestone ? "Lands" : "Runs",
                  description: selected.milestone
                    ? utc(selected.start)
                    : `${utc(selected.start)} — ${utc(selected.end)}`,
                },
                { term: "Points", description: selected.points },
              ]}
            />
          ) : null}

          {group ? (
            <div className="flex flex-col gap-1">
              <h3 className="type-eyebrow text-faint-foreground">In {group.label.split(" · ")[0]}</h3>
              <DataList divided>
                {group.tasks.map((task) => (
                  <DataListItem
                    key={task.id}
                    title={task.label}
                    description={task.statusLabel}
                    meta={utc(task.end)}
                    selected={task.id === selectedId}
                    onClick={() => setSelectedId(task.id)}
                  />
                ))}
              </DataList>
            </div>
          ) : null}
        </CardContent>
      </Card>
    </div>
  )
}