Project Roadmap
blockproductivityFull interactive project roadmap and deliverables surface: Gantt timeline with multi-scale zooming, work breakdown task tree, overall progress metrics, search and status/assignee filters, task detail slide-over Sheet, and Add Task modal dialog.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/project-roadmap.json$npx shadcn@latest add https://uipkge.dev/r/lit/project-roadmap.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/project-roadmap.json$bunx shadcn@latest add https://uipkge.dev/r/lit/project-roadmap.jsonManifest:
https://uipkge.dev/r/lit/project-roadmap.jsonInstalls to:src/uip/blocks/project-roadmap/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
heading | string | Engineering Q3 Deliverables | optional |
description | string | Track roadmap work breakdown, sprint milestones, and cross-team dependencies. | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-project-roadmap': UipProjectRoadmap
}
}Files installed (1)
src/uip/blocks/project-roadmap/project-roadmap.ts21.1 kB
import { LitElement, css, html } from 'lit' import { AlertCircle, Calendar, CheckCircle2, Clock, Download, Milestone, Plus, Search } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/badge/badge' import '../../components/button/button' import '../../components/card/card' import '../../components/checkbox/checkbox' import '../../components/dialog/dialog' import '../../components/gantt/gantt' import '../../components/input/input' import '../../components/label/label' import '../../components/select/select' import '../../components/sheet/sheet' import type { GanttScale, GanttTask, GanttTaskStatus } from '../../components/gantt/types' import type { UipInput } from '../../components/input/input' import type { UipSelect } from '../../components/select/select' function seedTasks(): GanttTask[] { return [ { id: 'task-1', name: 'System Architecture & Database Schema', startDate: '2026-08-01', endDate: '2026-08-08', progress: 100, status: 'done', priority: 'high', assignee: { name: 'Sarah Connor', initials: 'SC' } }, { id: 'task-2', name: 'Auth, RBAC & Multi-Tenant Session Engine', startDate: '2026-08-06', endDate: '2026-08-16', progress: 85, status: 'in-progress', priority: 'urgent', assignee: { name: 'Marcus Rivera', initials: 'MR' } }, { id: 'task-3', name: 'Real-time WebSocket Notification Bus', startDate: '2026-08-12', endDate: '2026-08-22', progress: 50, status: 'in-progress', priority: 'high', assignee: { name: 'Priya Nair', initials: 'PN' } }, { id: 'task-4', name: 'Alpha Core API Release Milestone', startDate: '2026-08-23', endDate: '2026-08-23', isMilestone: true, status: 'todo', priority: 'urgent' }, { id: 'task-5', name: 'Stripe Billing & Invoicing Integration', startDate: '2026-08-24', endDate: '2026-09-04', progress: 15, status: 'todo', priority: 'medium', assignee: { name: 'Sundar Krishnan', initials: 'SK' } }, { id: 'task-6', name: 'End-to-End Playwright Automated Testing', startDate: '2026-08-28', endDate: '2026-09-08', progress: 0, status: 'todo', priority: 'low', assignee: { name: 'Diane Cho', initials: 'DC' } }, { id: 'task-7', name: 'Production Cloudflare Pages Deployment', startDate: '2026-09-10', endDate: '2026-09-10', isMilestone: true, status: 'todo' }, ] } /** * <uip-project-roadmap> — the registry ProjectRoadmap block as a web component. * * Ports the full surface: header with Export/Add Task, metrics row, * search + status + assignee filters with a live count, the Gantt timeline, * a task detail sheet, and the Add Task dialog. The RoadmapMetrics / * RoadmapTaskDetail / RoadmapAddTaskDialog decomposition is folded into this * single file (multi-file blocks ship together, but one file suffices here); * class strings follow React's files. `tasks` is a settable property * (defaults to the demo seed). The timeline itself renders through the * existing <uip-gantt> primitive (`task-click` / `scale-change` events). */ export class UipProjectRoadmap extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { tasks: { attribute: false }, heading: {}, description: {}, searchQuery: { state: true }, statusFilter: { state: true }, assigneeFilter: { state: true }, scale: { state: true }, sheetOpen: { state: true }, selectedTaskId: { state: true }, addOpen: { state: true }, addName: { state: true }, addStart: { state: true }, addEnd: { state: true }, addStatus: { state: true }, addMilestone: { state: true }, addAssignee: { state: true }, } tasks: GanttTask[] = [] /** React's `title` (`title` is taken by HTMLElement). */ heading = 'Engineering Q3 Deliverables' description = 'Track roadmap work breakdown, sprint milestones, and cross-team dependencies.' private searchQuery = '' private statusFilter = 'all' private assigneeFilter = 'all' private scale: GanttScale = 'day' private sheetOpen = false private selectedTaskId: string | null = null private addOpen = false private addName = '' private addStart = '2026-08-20' private addEnd = '2026-08-28' private addStatus: 'todo' | 'in-progress' | 'done' = 'todo' private addMilestone = false private addAssignee = 'Marcus Rivera' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'project-roadmap') if (this.tasks.length === 0) this.tasks = seedTasks() } private get filteredTasks(): GanttTask[] { return this.tasks.filter((t) => { const matchesSearch = !this.searchQuery || t.name.toLowerCase().includes(this.searchQuery.toLowerCase()) const matchesStatus = this.statusFilter === 'all' || t.status === this.statusFilter const matchesAssignee = this.assigneeFilter === 'all' || t.assignee?.name === this.assigneeFilter return matchesSearch && matchesStatus && matchesAssignee }) } private get metrics(): { total: number; completed: number; inProgress: number; milestones: number; progress: number } { const total = this.tasks.length const completed = this.tasks.filter((t) => t.status === 'done').length const inProgress = this.tasks.filter((t) => t.status === 'in-progress').length const milestones = this.tasks.filter((t) => t.isMilestone).length const progress = total === 0 ? 0 : Math.round(this.tasks.reduce((acc, t) => acc + (t.progress ?? 0), 0) / total) return { total, completed, inProgress, milestones, progress } } private get selectedTask(): GanttTask | null { return this.tasks.find((t) => t.id === this.selectedTaskId) ?? null } private statusBadge(task: GanttTask): { label: string; variant: string; cls: string } { switch (task.status) { case 'done': return { label: 'Completed', variant: 'outline', cls: 'text-success border-success/30' } case 'in-progress': return { label: 'In Progress', variant: 'default', cls: '' } case 'blocked': return { label: 'Blocked', variant: 'destructive', cls: '' } default: return { label: 'To Do', variant: 'secondary', cls: '' } } } private submitAdd(e: Event) { e.preventDefault() if (!this.addName.trim()) return const task: GanttTask = { id: `task-${Date.now()}`, name: this.addName.trim(), startDate: this.addStart, endDate: this.addMilestone ? this.addStart : this.addEnd, status: this.addStatus as GanttTaskStatus, progress: this.addMilestone ? 100 : 0, isMilestone: this.addMilestone, assignee: { name: this.addAssignee, initials: this.addAssignee .split(' ') .map((p) => p[0]) .join(''), }, } this.tasks = [...this.tasks, task] this.dispatchEvent(new CustomEvent('task-add', { detail: task, bubbles: true, composed: true })) this.addOpen = false this.addName = '' this.addMilestone = false } render() { const m = this.metrics const filtered = this.filteredTasks const selected = this.selectedTask const badge = selected ? this.statusBadge(selected) : null return html` <div data-uipkge="" data-slot="project-roadmap" class="space-y-6"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">${this.heading}</h2> <p class="text-muted-foreground mt-0.5 text-sm">${this.description}</p> </div> <div class="flex items-center gap-2"> <uip-button aria-label="Download attachment" variant="outline" size="sm"> ${icon(Download, 'download', 'mr-1 size-3.5')} Export </uip-button> <uip-button size="sm" @click=${() => { this.addOpen = true }} > ${icon(Plus, 'plus', 'mr-1 size-3.5')} Add Task </uip-button> </div> </div> <div data-slot="roadmap-metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <uip-card class="bg-card/50 border-border/80 shadow-none"> <uip-card-content class="flex items-center justify-between p-3.5"> <div> <p class="text-muted-foreground text-xs font-medium">Overall Progress</p> <p class="text-foreground mt-0.5 font-mono text-xl font-bold">${m.progress}%</p> </div> <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-full"> ${icon(Clock, 'clock', 'size-4')} </div> </uip-card-content> </uip-card> <uip-card class="bg-card/50 border-border/80 shadow-none"> <uip-card-content class="flex items-center justify-between p-3.5"> <div> <p class="text-muted-foreground text-xs font-medium">In Progress</p> <p class="text-foreground mt-0.5 font-mono text-xl font-bold">${m.inProgress}</p> </div> <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-full"> ${icon(AlertCircle, 'circle-alert', 'size-4')} </div> </uip-card-content> </uip-card> <uip-card class="bg-card/50 border-border/80 shadow-none"> <uip-card-content class="flex items-center justify-between p-3.5"> <div> <p class="text-muted-foreground text-xs font-medium">Completed</p> <p class="text-foreground mt-0.5 font-mono text-xl font-bold">${m.completed}</p> </div> <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-full"> ${icon(CheckCircle2, 'circle-check-big', 'size-4')} </div> </uip-card-content> </uip-card> <uip-card class="bg-card/50 border-border/80 shadow-none"> <uip-card-content class="flex items-center justify-between p-3.5"> <div> <p class="text-muted-foreground text-xs font-medium">Milestones</p> <p class="text-foreground mt-0.5 font-mono text-xl font-bold">${m.milestones}</p> </div> <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-full"> ${icon(Milestone, 'milestone', 'size-4')} </div> </uip-card-content> </uip-card> </div> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex max-w-lg flex-1 flex-wrap items-center gap-2"> <uip-input .value=${this.searchQuery} placeholder="Filter tasks by name..." class="w-56 text-xs" @input=${(e: Event) => { this.searchQuery = (e.target as UipInput).value }} > <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span> </uip-input> <uip-select value=${this.statusFilter} class="h-8 w-32 text-xs" @change=${(e: Event) => { this.statusFilter = (e.target as UipSelect).value }} > <option value="all">All Status</option> <option value="done">Completed</option> <option value="in-progress">In Progress</option> <option value="todo">To Do</option> </uip-select> <uip-select value=${this.assigneeFilter} class="h-8 w-36 text-xs" @change=${(e: Event) => { this.assigneeFilter = (e.target as UipSelect).value }} > <option value="all">All Assignees</option> <option value="Sarah Connor">Sarah Connor</option> <option value="Marcus Rivera">Marcus Rivera</option> <option value="Priya Nair">Priya Nair</option> <option value="Sundar Krishnan">Sundar Krishnan</option> <option value="Diane Cho">Diane Cho</option> </uip-select> </div> <p class="text-muted-foreground font-mono text-xs"> Showing <span class="text-foreground font-bold">${filtered.length}</span> of ${this.tasks.length} deliverables <span class="text-muted-foreground/60 ml-2">(Right-click task for quick actions)</span> </p> </div> <uip-gantt .tasks=${filtered} scale=${this.scale} header-title=${this.heading} class="h-[480px]" @task-click=${(e: CustomEvent<{ task: GanttTask }>) => { this.selectedTaskId = e.detail.task.id this.sheetOpen = true }} @scale-change=${(e: CustomEvent<{ scale: GanttScale }>) => { this.scale = e.detail.scale }} ></uip-gantt> <uip-sheet ?open=${this.sheetOpen} heading=${selected?.name ?? ''} description=${selected ? `Task ID: ${selected.id}` : ''} @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.sheetOpen = e.detail.open }} > ${selected && badge ? html` <div class="space-y-2 border-b pb-4"> <div class="flex items-center gap-2"> <uip-badge variant=${badge.variant} class=${badge.cls}>${badge.label}</uip-badge> ${selected.isMilestone ? html`<uip-badge variant="outline" class="border-warning/30 text-warning">Milestone</uip-badge>` : ''} </div> </div> <div class="space-y-5 py-5 text-sm"> <div class="space-y-1.5"> <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Schedule</span> <div class="text-foreground flex items-center gap-2 font-mono text-xs"> ${icon(Calendar, 'calendar', 'text-muted-foreground size-4')} <span>${selected.startDate}</span> <span>→</span> <span>${selected.endDate}</span> </div> </div> ${selected.progress != null ? html` <div class="space-y-1.5"> <div class="flex items-center justify-between text-xs"> <span class="text-muted-foreground font-medium tracking-wider uppercase">Completion</span> <span class="font-mono font-bold">${selected.progress}%</span> </div> <div class="bg-muted h-2 w-full overflow-hidden rounded-full"> <div style="width: ${selected.progress}%" class="bg-primary h-full rounded-full transition-[width] duration-300" ></div> </div> </div> ` : ''} ${selected.assignee ? html` <div class="space-y-1.5"> <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Owner</span> <div class="flex items-center gap-2.5"> <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-full text-xs font-medium" > ${selected.assignee.initials || selected.assignee.name.charAt(0)} </div> <span class="text-foreground text-sm font-medium">${selected.assignee.name}</span> </div> </div> ` : ''} </div> ` : ''} </uip-sheet> <uip-dialog ?open=${this.addOpen} heading="Add Roadmap Task" description="Create a new scheduled deliverable or critical milestone in the project Gantt timeline." @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.addOpen = e.detail.open }} > <form @submit=${this.submitAdd} class="space-y-4 py-2"> <div class="space-y-1.5"> <uip-label for="roadmap-task-name">Task Name</uip-label> <uip-input id="roadmap-task-name" .value=${this.addName} placeholder="e.g. Design Token Hierarchy & Specs" required @input=${(e: Event) => { this.addName = (e.target as UipInput).value }} ></uip-input> </div> <div class="grid grid-cols-2 gap-3"> <div class="space-y-1.5"> <uip-label for="roadmap-start-date">Start Date</uip-label> <uip-input id="roadmap-start-date" type="date" .value=${this.addStart} required @input=${(e: Event) => { this.addStart = (e.target as UipInput).value }} ></uip-input> </div> <div class="space-y-1.5"> <uip-label for="roadmap-end-date">End Date</uip-label> <uip-input id="roadmap-end-date" type="date" .value=${this.addEnd} ?disabled=${this.addMilestone} required @input=${(e: Event) => { this.addEnd = (e.target as UipInput).value }} ></uip-input> </div> </div> <div class="border-border/80 bg-muted/20 flex items-center space-x-2 rounded-md border p-3"> <uip-checkbox id="roadmap-milestone-toggle" .checked=${this.addMilestone} @checked-change=${(e: CustomEvent<{ checked: boolean }>) => { this.addMilestone = e.detail.checked }} ></uip-checkbox> <div class="grid gap-0.5 leading-none"> <label for="roadmap-milestone-toggle" class="text-foreground cursor-pointer text-xs font-semibold select-none"> Key Project Milestone </label> <p class="text-muted-foreground text-xs"> Marks a zero-duration target deliverable (e.g. Beta Release, Audit sign-off) </p> </div> </div> <div class="grid grid-cols-2 gap-3"> <div class="space-y-1.5"> <uip-label>Initial Status</uip-label> <uip-select value=${this.addStatus} @change=${(e: Event) => { this.addStatus = (e.target as UipSelect).value as 'todo' | 'in-progress' | 'done' }} > <option value="todo">To Do</option> <option value="in-progress">In Progress</option> <option value="done">Completed</option> </uip-select> </div> <div class="space-y-1.5"> <uip-label>Assignee</uip-label> <uip-select value=${this.addAssignee} @change=${(e: Event) => { this.addAssignee = (e.target as UipSelect).value }} > <option value="Marcus Rivera">Marcus Rivera</option> <option value="Sarah Connor">Sarah Connor</option> <option value="Priya Nair">Priya Nair</option> <option value="Sundar Krishnan">Sundar Krishnan</option> <option value="Diane Cho">Diane Cho</option> </uip-select> </div> </div> </form> <div slot="footer"> <uip-button variant="outline" @click=${() => { this.addOpen = false }} >Cancel</uip-button > <uip-button @click=${this.submitAdd} ?disabled=${!this.addName.trim()} > ${icon(Plus, 'plus', 'mr-1.5 size-3.5')} Create Task </uip-button> </div> </uip-dialog> </div> ` } } customElements.get('uip-project-roadmap') || customElements.define('uip-project-roadmap', UipProjectRoadmap) declare global { interface HTMLElementTagNameMap { 'uip-project-roadmap': UipProjectRoadmap } }
Raw manifest:https://uipkge.dev/r/lit/project-roadmap.json