feat: restore jira-style gantt board with responsive timeline UI

This commit is contained in:
Matt Bruce 2026-02-18 00:00:55 -06:00
parent db188bec80
commit ae11914a39
8 changed files with 2636 additions and 104 deletions

1578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,21 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@types/frappe-gantt": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"firebase": "^12.9.0",
"framer-motion": "^12.34.1",
"frappe-gantt": "^1.2.1",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

1
src/app/frappe-gantt.css Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,26 +1,122 @@
@import "tailwindcss";
@import "./frappe-gantt.css";
:root {
--background: #ffffff;
--foreground: #171717;
--app-bg: #f3f6fb;
--app-surface: #ffffff;
--app-surface-soft: #eef3fa;
--app-border: #b9cbe0;
--app-text: #0f172a;
--app-muted: #334155;
--app-accent: #0369a1;
--app-accent-strong: #075985;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--app-bg);
--color-foreground: var(--app-text);
--font-sans: var(--font-body);
--font-mono: var(--font-code);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--app-bg: #0f172a;
--app-surface: #111827;
--app-surface-soft: #172034;
--app-border: #334155;
--app-text: #e5e7eb;
--app-muted: #cbd5e1;
--app-accent: #38bdf8;
--app-accent-strong: #0ea5e9;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
background:
radial-gradient(circle at 15% -20%, rgba(56, 189, 248, 0.18), transparent 35%),
radial-gradient(circle at 90% 20%, rgba(14, 165, 233, 0.12), transparent 30%),
var(--app-bg);
color: var(--app-text);
font-family: var(--font-body), sans-serif;
font-size: 15px;
line-height: 1.5;
}
h1,
h2,
h3 {
font-family: var(--font-heading), sans-serif;
}
button,
select,
input,
textarea {
font: inherit;
}
input:not([type='range']):not([type='checkbox']),
select,
textarea {
background-color: var(--app-surface);
color: var(--app-text);
border-color: var(--app-border);
}
input::placeholder,
textarea::placeholder {
color: color-mix(in srgb, var(--app-muted) 75%, transparent);
}
input[type='date'] {
width: 100%;
min-width: 0;
max-width: 100%;
}
:focus-visible {
outline: 2px solid color-mix(in srgb, var(--app-accent) 65%, white);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
.bar-wrapper.task-backlog .bar {
fill: #94a3b8;
}
.bar-wrapper.task-in-progress .bar {
fill: #0ea5e9;
}
.bar-wrapper.task-blocked .bar {
fill: #fb7185;
}
.bar-wrapper.task-done .bar {
fill: #34d399;
}
.gantt-shell,
.gantt-shell .gantt-container {
background-color: var(--app-surface) !important;
}
.gantt-shell .gantt .grid-background {
fill: var(--app-surface) !important;
}
.gantt-shell .gantt-container .grid-header {
background-color: var(--app-surface) !important;
}

View File

@ -1,34 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next'
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
import './globals.css'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const headingFont = Lexend({
variable: '--font-heading',
subsets: ['latin'],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const bodyFont = Source_Sans_3({
variable: '--font-body',
subsets: ['latin'],
})
const monoFont = JetBrains_Mono({
variable: '--font-code',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: 'OpenClaw Gantt Board',
description: 'Project timeline and execution workspace',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
{children}
</body>
</html>
);
)
}

View File

@ -1,65 +1,730 @@
import Image from "next/image";
'use client'
import { useMemo, useState } from 'react'
import {
Activity,
AlertTriangle,
CalendarDays,
CheckCircle2,
Clock3,
LayoutList,
Plus,
Search,
Trash2,
} from 'lucide-react'
import { GanttChart } from '@/components/ui/GanttChart'
import { type TaskPriority, type TaskStatus, useGanttStore } from '@/stores/useGanttStore'
const STATUS_OPTIONS: Array<{ value: TaskStatus; label: string }> = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
]
const PRIORITY_OPTIONS: Array<{ value: TaskPriority; label: string }> = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
]
const VIEW_MODE_OPTIONS = ['Day', 'Week', 'Month', 'Year'] as const
function addDays(dateISO: string, days: number): string {
const date = new Date(dateISO)
date.setDate(date.getDate() + days)
return date.toISOString().slice(0, 10)
}
function normalizeStatus(progress: number, status?: string): TaskStatus {
if (status === 'backlog' || status === 'in-progress' || status === 'blocked' || status === 'done') {
return status
}
if (progress >= 100) return 'done'
if (progress > 0) return 'in-progress'
return 'backlog'
}
function normalizePriority(priority?: string): TaskPriority {
if (priority === 'low' || priority === 'medium' || priority === 'high' || priority === 'critical') {
return priority
}
return 'medium'
}
function statusTone(status: TaskStatus): string {
if (status === 'done') return 'bg-emerald-100 text-emerald-700'
if (status === 'blocked') return 'bg-rose-100 text-rose-700'
if (status === 'in-progress') return 'bg-sky-100 text-sky-700'
return 'bg-slate-100 text-slate-700'
}
function priorityTone(priority: TaskPriority): string {
if (priority === 'critical') return 'bg-rose-100 text-rose-700'
if (priority === 'high') return 'bg-amber-100 text-amber-700'
if (priority === 'medium') return 'bg-sky-100 text-sky-700'
return 'bg-slate-100 text-slate-700'
}
export default function Home() {
const { projects, tasks, addProject, addTask, updateTask, deleteTask } = useGanttStore()
const [selectedProject, setSelectedProject] = useState(projects[0] ?? '')
const [newProjectName, setNewProjectName] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | TaskStatus>('all')
const [viewMode, setViewMode] = useState<(typeof VIEW_MODE_OPTIONS)[number]>('Week')
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
const [taskName, setTaskName] = useState('')
const [taskAssignee, setTaskAssignee] = useState('')
const [taskStatus, setTaskStatus] = useState<TaskStatus>('backlog')
const [taskPriority, setTaskPriority] = useState<TaskPriority>('medium')
const todayISO = new Date().toISOString().slice(0, 10)
const [taskStart, setTaskStart] = useState(todayISO)
const [taskEnd, setTaskEnd] = useState(addDays(todayISO, 2))
const activeProject = projects.includes(selectedProject) ? selectedProject : (projects[0] ?? '')
const projectTasks = useMemo(
() =>
tasks
.filter((task) => task.project === activeProject)
.map((task) => ({
...task,
assignee: task.assignee?.trim() || 'Unassigned',
status: normalizeStatus(task.progress, task.status),
priority: normalizePriority(task.priority),
})),
[tasks, activeProject]
)
const taskIndexById = useMemo(
() => new Map(projectTasks.map((task, index) => [task.id, index + 1])),
[projectTasks]
)
const filteredTasks = useMemo(
() =>
projectTasks.filter((task) => {
const matchesSearch =
!searchQuery ||
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.assignee.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || task.status === statusFilter
return matchesSearch && matchesStatus
}),
[projectTasks, searchQuery, statusFilter]
)
const selectedVisibleTaskIds = useMemo(
() => selectedTaskIds.filter((id) => filteredTasks.some((task) => task.id === id)),
[selectedTaskIds, filteredTasks]
)
const metrics = useMemo(() => {
const total = projectTasks.length
const done = projectTasks.filter((task) => task.status === 'done').length
const blocked = projectTasks.filter((task) => task.status === 'blocked').length
const overdue = projectTasks.filter((task) => task.end < todayISO && task.status !== 'done').length
const avgProgress =
total === 0 ? 0 : Math.round(projectTasks.reduce((sum, task) => sum + task.progress, 0) / total)
return { total, done, blocked, overdue, avgProgress }
}, [projectTasks, todayISO])
const isAllVisibleSelected =
filteredTasks.length > 0 && filteredTasks.every((task) => selectedVisibleTaskIds.includes(task.id))
const projectCode =
activeProject
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 4)
.toUpperCase() || 'PRJ'
const handleCreateProject = () => {
const trimmed = newProjectName.trim()
if (!trimmed) return
addProject(trimmed)
setSelectedProject(trimmed)
setNewProjectName('')
}
const handleCreateTask = () => {
const trimmed = taskName.trim()
if (!trimmed || !activeProject) return
const normalizedEnd = taskEnd < taskStart ? taskStart : taskEnd
const initialProgress = taskStatus === 'done' ? 100 : taskStatus === 'in-progress' ? 35 : 0
addTask({
name: trimmed,
assignee: taskAssignee.trim() || 'Unassigned',
status: taskStatus,
priority: taskPriority,
start: taskStart,
end: normalizedEnd,
progress: initialProgress,
dependencies: [],
project: activeProject,
})
setTaskName('')
setTaskAssignee('')
setTaskStart(todayISO)
setTaskEnd(addDays(todayISO, 2))
setTaskStatus('backlog')
setTaskPriority('medium')
}
const handleToggleSelection = (taskId: string, checked: boolean) => {
setSelectedTaskIds((current) => {
if (checked) return Array.from(new Set([...current, taskId]))
return current.filter((id) => id !== taskId)
})
}
const handleToggleAllVisible = (checked: boolean) => {
setSelectedTaskIds((current) => {
const visibleIds = filteredTasks.map((task) => task.id)
if (checked) return Array.from(new Set([...current, ...visibleIds]))
return current.filter((id) => !visibleIds.includes(id))
})
}
const handleBulkStatus = (status: TaskStatus) => {
selectedVisibleTaskIds.forEach((taskId) => {
const updates: { status: TaskStatus; progress?: number } = { status }
if (status === 'done') updates.progress = 100
if (status === 'backlog') updates.progress = 0
updateTask(taskId, updates)
})
}
const handleBulkDelete = () => {
selectedVisibleTaskIds.forEach((taskId) => deleteTask(taskId))
setSelectedTaskIds((current) => current.filter((id) => !selectedVisibleTaskIds.includes(id)))
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<main className="relative min-h-screen overflow-x-hidden bg-[var(--app-bg)] text-[var(--app-text)]">
<div className="pointer-events-none absolute -left-28 -top-28 h-80 w-80 rounded-full bg-sky-300/25 blur-3xl" />
<div className="pointer-events-none absolute -right-32 top-28 h-80 w-80 rounded-full bg-cyan-200/35 blur-3xl" />
<div className="relative mx-auto w-full max-w-[1500px] p-4 md:p-6">
<div className="flex flex-col gap-4 xl:flex-row">
<aside className="hidden w-80 shrink-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 shadow-[0_24px_48px_-32px_rgba(2,6,23,0.35)] xl:flex xl:flex-col xl:gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--app-muted)]">OpenClaw</p>
<h2 className="mt-1 font-[family-name:var(--font-heading)] text-2xl font-semibold">Delivery Command</h2>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
<div className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface-soft)] p-3">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">
Create Project
</label>
<div className="flex gap-2">
<input
value={newProjectName}
onChange={(event) => setNewProjectName(event.target.value)}
placeholder="e.g. Mobile Revamp"
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={handleCreateProject}
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-[var(--app-accent)] text-white transition hover:bg-[var(--app-accent-strong)]"
aria-label="Add project"
>
Documentation
</a>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Projects</p>
<div className="space-y-1">
{projects.map((project) => {
const taskCount = tasks.filter((task) => task.project === project).length
const active = project === activeProject
return (
<button
key={project}
type="button"
onClick={() => setSelectedProject(project)}
className={`flex w-full cursor-pointer items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${
active
? 'border-sky-300 bg-sky-50 text-sky-900'
: 'border-transparent hover:border-[var(--app-border)] hover:bg-[var(--app-surface-soft)]'
}`}
>
<span className="truncate">{project}</span>
<span className="rounded bg-white px-2 py-0.5 text-xs text-[var(--app-muted)]">{taskCount}</span>
</button>
)
})}
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Status Filter</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`h-10 cursor-pointer rounded-lg border text-xs font-semibold transition ${
statusFilter === 'all'
? 'border-sky-300 bg-sky-50 text-sky-700'
: 'border-[var(--app-border)] bg-white text-[var(--app-muted)] hover:bg-[var(--app-surface-soft)]'
}`}
>
All
</button>
{STATUS_OPTIONS.map((status) => (
<button
key={status.value}
type="button"
onClick={() => setStatusFilter(status.value)}
className={`h-10 cursor-pointer rounded-lg border text-xs font-semibold transition ${
statusFilter === status.value
? 'border-sky-300 bg-sky-50 text-sky-700'
: 'border-[var(--app-border)] bg-white text-[var(--app-muted)] hover:bg-[var(--app-surface-soft)]'
}`}
>
{status.label}
</button>
))}
</div>
</div>
{selectedVisibleTaskIds.length > 0 && (
<div className="space-y-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-surface-soft)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">
Bulk Actions ({selectedVisibleTaskIds.length})
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleBulkStatus('in-progress')}
className="h-10 flex-1 cursor-pointer rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white transition hover:bg-sky-700"
>
Start
</button>
<button
type="button"
onClick={() => handleBulkStatus('done')}
className="h-10 flex-1 cursor-pointer rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
>
Done
</button>
<button
type="button"
onClick={handleBulkDelete}
className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg bg-rose-100 text-rose-700 transition hover:bg-rose-200"
aria-label="Delete selected tasks"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
</aside>
<section className="min-w-0 flex-1 space-y-4">
<header className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 shadow-[0_24px_48px_-32px_rgba(2,6,23,0.35)] md:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--app-muted)]">
Jira-style Execution Board
</p>
<h1 className="mt-1 font-[family-name:var(--font-heading)] text-2xl font-semibold tracking-tight">
{activeProject || 'Create a project to begin'}
</h1>
</div>
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap lg:ml-auto lg:max-w-3xl">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--app-muted)]" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search issue summary or assignee"
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white pl-10 pr-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
/>
</div>
<select
value={activeProject}
onChange={(event) => setSelectedProject(event.target.value)}
className="h-11 rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 xl:hidden"
>
{projects.map((project) => (
<option key={project} value={project}>
{project}
</option>
))}
</select>
<div className="grid h-11 grid-cols-4 rounded-lg border border-[var(--app-border)] bg-white p-1">
{VIEW_MODE_OPTIONS.map((mode) => (
<button
key={mode}
type="button"
onClick={() => setViewMode(mode)}
aria-pressed={viewMode === mode}
className={`h-full cursor-pointer rounded-md px-2 text-xs font-semibold transition ${
viewMode === mode
? 'bg-slate-900 text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.15)]'
: 'text-slate-700 hover:bg-slate-100'
}`}
>
{mode}
</button>
))}
</div>
</div>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Completion</span>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.avgProgress}%</p>
<p className="mt-2 text-sm text-[var(--app-muted)]">
{metrics.done} / {metrics.total} issues done
</p>
</article>
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Blocked</span>
<AlertTriangle className="h-4 w-4 text-rose-600" />
</div>
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.blocked}</p>
<p className="mt-2 text-sm text-[var(--app-muted)]">Needs unblock action</p>
</article>
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Overdue</span>
<Clock3 className="h-4 w-4 text-amber-600" />
</div>
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.overdue}</p>
<p className="mt-2 text-sm text-[var(--app-muted)]">Past target date</p>
</article>
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">In Scope</span>
<Activity className="h-4 w-4 text-sky-600" />
</div>
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{filteredTasks.length}</p>
<p className="mt-2 text-sm text-[var(--app-muted)]">Matching active filters</p>
</article>
</div>
<section className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 md:p-5">
<div className="mb-3 flex items-center gap-2">
<Plus className="h-4 w-4 text-[var(--app-accent)]" />
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Create Issue</h2>
</div>
<div className="space-y-3">
<label className="min-w-0 block">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Summary</span>
<input
value={taskName}
onChange={(event) => setTaskName(event.target.value)}
placeholder="Add issue title"
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
/>
</label>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<label className="min-w-0">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Assignee</span>
<input
value={taskAssignee}
onChange={(event) => setTaskAssignee(event.target.value)}
placeholder="Unassigned"
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="min-w-0">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Status</span>
<select
value={taskStatus}
onChange={(event) => setTaskStatus(event.target.value as TaskStatus)}
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
>
{STATUS_OPTIONS.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</label>
<label className="min-w-0">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Priority</span>
<select
value={taskPriority}
onChange={(event) => setTaskPriority(event.target.value as TaskPriority)}
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
>
{PRIORITY_OPTIONS.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap gap-3">
<label className="min-w-[220px] max-w-[260px] flex-1">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Start</span>
<input
type="date"
value={taskStart}
onChange={(event) => setTaskStart(event.target.value)}
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
/>
</label>
<label className="min-w-[220px] max-w-[260px] flex-1">
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">End</span>
<input
type="date"
value={taskEnd}
onChange={(event) => setTaskEnd(event.target.value)}
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
/>
</label>
</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={handleCreateTask}
disabled={!taskName.trim() || !activeProject}
className="inline-flex h-11 cursor-pointer items-center gap-2 rounded-lg bg-[var(--app-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--app-accent-strong)] disabled:cursor-not-allowed disabled:bg-slate-300"
>
<Plus className="h-4 w-4" />
Create Issue
</button>
</div>
</section>
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<section className="min-w-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)]">
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--app-border)] px-4 py-3">
<div className="inline-flex items-center gap-2">
<LayoutList className="h-4 w-4 text-[var(--app-accent)]" />
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Issue Matrix</h2>
</div>
{selectedVisibleTaskIds.length > 0 && (
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => handleBulkStatus('done')}
className="h-9 cursor-pointer rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
>
Mark Done
</button>
<button
type="button"
onClick={handleBulkDelete}
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg bg-rose-100 text-rose-700 transition hover:bg-rose-200"
aria-label="Delete selected tasks"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</header>
<div className="overflow-x-auto overflow-y-hidden">
<table className="min-w-[860px] text-sm md:min-w-[960px]">
<thead>
<tr className="border-b border-[var(--app-border)] bg-[var(--app-surface-soft)] text-left text-xs uppercase tracking-[0.12em] text-[var(--app-muted)]">
<th className="px-3 py-2">
<input
type="checkbox"
checked={isAllVisibleSelected}
onChange={(event) => handleToggleAllVisible(event.target.checked)}
className="h-4 w-4 cursor-pointer rounded border-[var(--app-border)]"
aria-label="Select all visible issues"
/>
</th>
<th className="px-3 py-2">Issue</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Priority</th>
<th className="px-3 py-2">Assignee</th>
<th className="px-3 py-2">Dates</th>
<th className="px-3 py-2">Progress</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{filteredTasks.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-12 text-center text-sm text-[var(--app-muted)]">
No issues match the current filters.
</td>
</tr>
) : (
filteredTasks.map((task) => {
const issueNumber = taskIndexById.get(task.id) ?? 0
const issueKey = `${projectCode}-${issueNumber}`
return (
<tr key={task.id} className="border-b border-[var(--app-border)] hover:bg-[var(--app-surface-soft)]">
<td className="px-3 py-3 align-top">
<input
type="checkbox"
checked={selectedTaskIds.includes(task.id)}
onChange={(event) => handleToggleSelection(task.id, event.target.checked)}
className="h-4 w-4 cursor-pointer rounded border-[var(--app-border)]"
aria-label={`Select ${task.name}`}
/>
</td>
<td className="px-3 py-3">
<p className="font-mono text-xs text-[var(--app-muted)]">{issueKey}</p>
<p className="mt-1 font-medium text-[var(--app-text)]">{task.name}</p>
</td>
<td className="px-3 py-3">
<select
value={task.status}
onChange={(event) => updateTask(task.id, { status: event.target.value as TaskStatus })}
className={`h-10 w-full cursor-pointer rounded-lg border border-transparent px-2 text-sm font-semibold outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 ${statusTone(task.status)}`}
>
{STATUS_OPTIONS.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</td>
<td className="px-3 py-3">
<select
value={task.priority}
onChange={(event) => updateTask(task.id, { priority: event.target.value as TaskPriority })}
className={`h-10 w-full cursor-pointer rounded-lg border border-transparent px-2 text-sm font-semibold outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 ${priorityTone(task.priority)}`}
>
{PRIORITY_OPTIONS.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</td>
<td className="px-3 py-3">
<input
value={task.assignee}
onChange={(event) => updateTask(task.id, { assignee: event.target.value })}
className="h-10 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
/>
</td>
<td className="px-3 py-3">
<div className="grid w-36 max-w-full gap-1">
<input
type="date"
value={task.start}
onChange={(event) => updateTask(task.id, { start: event.target.value })}
className="h-9 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-xs outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
/>
<input
type="date"
value={task.end}
onChange={(event) => updateTask(task.id, { end: event.target.value })}
className="h-9 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-xs outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
/>
</div>
</td>
<td className="px-3 py-3">
<div className="min-w-36">
<input
type="range"
min={0}
max={100}
value={task.progress}
onChange={(event) => updateTask(task.id, { progress: Number(event.target.value) })}
className="h-2 w-full cursor-pointer accent-sky-600"
/>
<p className="mt-1 text-xs font-semibold text-[var(--app-muted)]">{task.progress}%</p>
</div>
</td>
<td className="px-3 py-3 align-top">
<button
type="button"
onClick={() => {
deleteTask(task.id)
setSelectedTaskIds((current) => current.filter((id) => id !== task.id))
}}
className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-rose-700 transition hover:bg-rose-100"
aria-label={`Delete ${task.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
<section className="min-w-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)]">
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--app-border)] px-4 py-3">
<div className="inline-flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-[var(--app-accent)]" />
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Timeline</h2>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 rounded bg-slate-100 px-2 py-1 font-semibold text-slate-700">
<span className="h-2 w-2 rounded-full bg-slate-500" />
Backlog
</span>
<span className="inline-flex items-center gap-1 rounded bg-sky-100 px-2 py-1 font-semibold text-sky-700">
<span className="h-2 w-2 rounded-full bg-sky-500" />
In Progress
</span>
<span className="inline-flex items-center gap-1 rounded bg-rose-100 px-2 py-1 font-semibold text-rose-700">
<span className="h-2 w-2 rounded-full bg-rose-500" />
Blocked
</span>
<span className="inline-flex items-center gap-1 rounded bg-emerald-100 px-2 py-1 font-semibold text-emerald-700">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Done
</span>
</div>
</header>
{activeProject ? (
<GanttChart project={activeProject} viewMode={viewMode} />
) : (
<div className="px-4 py-10 text-center text-sm text-[var(--app-muted)]">Create a project to see timeline data.</div>
)}
</section>
</div>
</section>
</div>
</div>
</main>
</div>
);
)
}

View File

@ -0,0 +1,85 @@
'use client'
import { useEffect, useRef } from 'react'
import { type TaskStatus, useGanttStore } from '@/stores/useGanttStore'
interface GanttChartProps {
project: string
viewMode?: 'Day' | 'Week' | 'Month' | 'Year'
}
function toDateString(value: string | Date): string {
if (typeof value === 'string') return value
return value.toISOString().slice(0, 10)
}
function getTaskStatus(progress: number, status?: string): TaskStatus {
if (status === 'backlog' || status === 'in-progress' || status === 'blocked' || status === 'done') {
return status
}
if (progress >= 100) return 'done'
if (progress > 0) return 'in-progress'
return 'backlog'
}
export function GanttChart({ project, viewMode = 'Week' }: GanttChartProps) {
const chartRef = useRef<HTMLDivElement>(null)
const { tasks, updateTask } = useGanttStore()
const projectTasks = tasks.filter(task => task.project === project)
useEffect(() => {
if (!chartRef.current || projectTasks.length === 0) {
if (chartRef.current) chartRef.current.innerHTML = ''
return
}
let disposed = false
const container = chartRef.current
;(async () => {
const { default: FrappeGantt } = await import('frappe-gantt')
if (disposed) return
container.innerHTML = ''
new FrappeGantt(
container,
projectTasks.map(task => ({
id: task.id,
name: task.name,
start: task.start,
end: task.end,
progress: task.progress,
dependencies: task.dependencies,
custom_class: `task-${getTaskStatus(task.progress, task.status)}`
})),
{
on_date_change: (task, start, end) => {
if (!task.id) return
updateTask(task.id, { start: toDateString(start), end: toDateString(end) })
},
on_progress_change: (task, progress) => {
if (!task.id) return
updateTask(task.id, { progress })
},
view_mode: viewMode,
bar_height: 30,
bar_corner_radius: 3,
container_height: 560,
padding: 18,
date_format: 'YYYY-MM-DD'
}
)
})()
return () => {
disposed = true
container.innerHTML = ''
}
}, [projectTasks, project, updateTask, viewMode])
return (
<div ref={chartRef} className="gantt-shell w-full h-[600px] border rounded-lg overflow-auto" />
)
}

118
src/stores/useGanttStore.ts Normal file
View File

@ -0,0 +1,118 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type TaskStatus = 'backlog' | 'in-progress' | 'blocked' | 'done'
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical'
export interface Task {
id: string
name: string
start: string
end: string
progress: number
dependencies: string[]
project: string
assignee: string
status: TaskStatus
priority: TaskPriority
}
interface GanttStore {
projects: string[]
tasks: Task[]
addTask: (task: Omit<Task, 'id'>) => void
updateTask: (id: string, updates: Partial<Task>) => void
deleteTask: (id: string) => void
addProject: (name: string) => void
}
export const useGanttStore = create<GanttStore>()(
persist(
(set) => ({
projects: ['OpenClaw iOS', 'BedrockTestApp', 'Web Gantt', 'Skills Update'],
tasks: [
{
id: '1',
name: 'BedrockTestApp Icons',
start: '2026-02-17',
end: '2026-02-18',
progress: 50,
dependencies: [],
project: 'BedrockTestApp',
assignee: 'Matt',
status: 'in-progress',
priority: 'high'
},
{
id: '2',
name: 'Assets Complete',
start: '2026-02-18',
end: '2026-02-20',
progress: 20,
dependencies: ['1'],
project: 'BedrockTestApp',
assignee: 'Ari',
status: 'backlog',
priority: 'medium'
},
{
id: '3',
name: 'MoodWeave Recreate',
start: '2026-02-18',
end: '2026-02-25',
progress: 0,
dependencies: [],
project: 'OpenClaw iOS',
assignee: 'Sam',
status: 'blocked',
priority: 'critical'
},
{
id: '4',
name: 'Firebase Auth',
start: '2026-02-18',
end: '2026-02-21',
progress: 0,
dependencies: [],
project: 'Web Gantt',
assignee: 'Priya',
status: 'backlog',
priority: 'medium'
}
],
addTask: (task) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
...task,
assignee: task.assignee.trim() || 'Unassigned',
}
]
})),
updateTask: (id, updates) =>
set((state) => ({
tasks: state.tasks.map(t => t.id === id ? { ...t, ...updates } : t)
})),
deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter(t => t.id !== id)
})),
addProject: (name) =>
set((state) => {
const trimmed = name.trim()
if (!trimmed || state.projects.includes(trimmed)) {
return state
}
return {
projects: [...state.projects, trimmed]
}
})
}),
{
name: 'gantt-storage'
}
)
)