/projects/timelineProject 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/projects-timelineNeeds 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 { 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>
)
}/**
* 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 }
}"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 } }
}"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>
)
}