Menu

Change language

GitHub repo

Project Roadmap

blockproductivity

Full 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

$npx shadcn@latest add https://uipkge.dev/r/lit/project-roadmap.json
Manifest:https://uipkge.dev/r/lit/project-roadmap.jsonInstalls to:src/uip/blocks/project-roadmap/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
headingstringEngineering Q3 Deliverablesoptional
descriptionstringTrack 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.

HTMLElementTagNameMap
interface 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>&rarr;</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