From 9f111b97dea6338e370dce8c8d616e8aa0c83924 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 18:08:40 +0400 Subject: [PATCH 1/2] feat(work-item): make the Gantt timeline interactive (zoom + drag-to-reschedule) The Gantt was a read-only timeline. It now supports: - Zoom: step the day scale in/out with toolbar controls (14-56 px/day). - Drag-to-reschedule: drag a bar to move start + target together, or drag either edge to change just the start or the target. Changes commit through the existing onUpdateIssue path (same PATCH the list/board/calendar use), with a live preview while dragging and clamping so a resized edge can't cross the other. A drag doesn't trigger navigation; a plain click still opens the issue. Dependency lines between bars are a planned follow-up. Closes #180 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../work-item/layouts/IssueLayoutGantt.tsx | 240 +++++++++++++++--- apps/web/src/pages/IssueListPage.tsx | 4 +- 2 files changed, 207 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx index f4c7f734..ac80902b 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx @@ -1,26 +1,38 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'; import { PriorityIcon } from '../IssueRowCells'; import type { Priority } from '../../../types'; +import type { IssueApiResponse } from '../../../api/types'; import { issueDisplayId, type IssueLayoutProps } from './IssueLayoutTypes'; const DAY_MS = 24 * 3600 * 1000; -const DAY_PX = 28; // width per day on the timeline; pannable, not zoomable yet +// Zoom levels: pixels per day. The middle value matches the previous fixed size. +const ZOOM_LEVELS = [14, 20, 28, 40, 56]; +const DEFAULT_ZOOM = 2; + +type DragMode = 'move' | 'start' | 'end'; +interface DragState { + id: string; + mode: DragMode; + startClientX: number; + origStart: number; + origEnd: number; + deltaDays: number; + moved: boolean; +} /** - * Lightweight Gantt — horizontal timeline of bars positioned by start_date and + * Interactive Gantt — a horizontal timeline of bars positioned by start_date and * target_date. Issues without both dates fall into a sidebar "Undated" list. * - * Implementation notes: - * - We compute the visible window from min(start_date) to max(target_date) - * across all dated issues, with a one-week padding either side. That keeps - * the chart compact for short-running projects. - * - The user can shift the window by ±7 days with the prev/next controls. - * Real zoom + drag-to-reschedule are deferred. - * - Bar color comes from `state.color`. - * - Sidebar (left) shows id + name; the chart (right) is horizontally - * scrollable for projects whose range exceeds the viewport. + * Interactions (#180): + * - Zoom the day scale in/out with the toolbar controls. + * - Pan the window by ±7 days with the prev/next controls. + * - Drag a bar to reschedule (moves start + target together); drag either edge + * to change just the start or the target. Commits via onUpdateIssue on drop. + * Dependency lines are a planned follow-up. */ export function IssueLayoutGantt({ project, @@ -29,7 +41,9 @@ export function IssueLayoutGantt({ issueHref, now, projectsById, + onUpdateIssue, }: IssueLayoutProps) { + const navigate = useNavigate(); const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); const dated = useMemo( @@ -39,8 +53,11 @@ export function IssueLayoutGantt({ const undated = useMemo(() => issues.filter((i) => !i.start_date || !i.target_date), [issues]); const [shiftDays, setShiftDays] = useState(0); + const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM); + const dayPx = ZOOM_LEVELS[zoomIdx]; + const canEdit = Boolean(onUpdateIssue); - const window = useMemo(() => { + const viewWindow = useMemo(() => { if (dated.length === 0) { const today = startOfDay(new Date(now)); return { start: today.getTime(), end: today.getTime() + 21 * DAY_MS }; @@ -61,15 +78,79 @@ export function IssueLayoutGantt({ return { start: min - pad + shiftDays * DAY_MS, end: max + pad + shiftDays * DAY_MS }; }, [dated, now, shiftDays]); - const totalDays = Math.max(1, Math.round((window.end - window.start) / DAY_MS) + 1); + const totalDays = Math.max(1, Math.round((viewWindow.end - viewWindow.start) / DAY_MS) + 1); const days = useMemo(() => { const arr: number[] = []; - for (let i = 0; i < totalDays; i++) arr.push(window.start + i * DAY_MS); + for (let i = 0; i < totalDays; i++) arr.push(viewWindow.start + i * DAY_MS); return arr; - }, [window.start, totalDays]); + }, [viewWindow.start, totalDays]); const todayMs = startOfDay(new Date(now)).getTime(); - const todayOffset = Math.round((todayMs - window.start) / DAY_MS); + const todayOffset = Math.round((todayMs - viewWindow.start) / DAY_MS); + + // The active drag lives in a ref (so the window listeners read live values + // without re-subscribing), mirrored into state so the render — which must not + // read a ref — can preview the bar's new position. + const dragRef = useRef(null); + const suppressClickRef = useRef(false); + const [dragPreview, setDragPreview] = useState<{ + id: string; + mode: DragMode; + deltaDays: number; + } | null>(null); + + useEffect(() => { + const onMove = (e: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const deltaDays = Math.round((e.clientX - d.startClientX) / dayPx); + if (deltaDays !== d.deltaDays) { + d.deltaDays = deltaDays; + if (deltaDays !== 0) d.moved = true; + setDragPreview({ id: d.id, mode: d.mode, deltaDays }); + } + }; + const onUp = () => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + if (d.moved) { + suppressClickRef.current = true; // don't navigate on the drag-release click + if (onUpdateIssue && d.deltaDays !== 0) { + const { start, end } = applyDragDelta(d.mode, d.deltaDays, d.origStart, d.origEnd); + const patch: { start_date?: string; target_date?: string } = {}; + if (d.mode !== 'end') patch.start_date = fmtDay(start); + if (d.mode !== 'start') patch.target_date = fmtDay(end); + onUpdateIssue(d.id, patch); + } + } + setDragPreview(null); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + }, [dayPx, onUpdateIssue]); + + const beginDrag = (e: React.PointerEvent, issue: IssueApiResponse, mode: DragMode) => { + if (!canEdit || e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const origStart = parseDay(issue.start_date!) ?? viewWindow.start; + const origEnd = parseDay(issue.target_date!) ?? origStart; + dragRef.current = { + id: issue.id, + mode, + startClientX: e.clientX, + origStart, + origEnd, + deltaDays: 0, + moved: false, + }; + setDragPreview({ id: issue.id, mode, deltaDays: 0 }); + }; return (
@@ -91,7 +172,7 @@ export function IssueLayoutGantt({

- {fmtRange(window.start, window.end)} + {fmtRange(viewWindow.start, viewWindow.end)}

- - {dated.length} dated · {undated.length} undated - +
+ + + + {dated.length} dated · {undated.length} undated + +
{dated.length === 0 ? ( @@ -142,7 +243,7 @@ export function IssueLayoutGantt({ {/* Timeline */} -
+
{/* Day-cell header */}
{days.map((ms, i) => { @@ -152,7 +253,7 @@ export function IssueLayoutGantt({
{isMonthStart && ( @@ -169,7 +270,7 @@ export function IssueLayoutGantt({ {todayOffset >= 0 && todayOffset < totalDays && (
)} @@ -177,26 +278,68 @@ export function IssueLayoutGantt({ {/* Bars */}
    {dated.map((issue) => { - const start = parseDay(issue.start_date!) ?? window.start; - const end = parseDay(issue.target_date!) ?? start; - const offset = Math.max(0, Math.round((start - window.start) / DAY_MS)); + let start = parseDay(issue.start_date!) ?? viewWindow.start; + let end = parseDay(issue.target_date!) ?? start; + const dragging = dragPreview?.id === issue.id; + if (dragging && dragPreview.deltaDays !== 0) { + const preview = applyDragDelta( + dragPreview.mode, + dragPreview.deltaDays, + start, + end, + ); + start = preview.start; + end = preview.end; + } + const offset = Math.max(0, Math.round((start - viewWindow.start) / DAY_MS)); const span = Math.max(1, Math.round((end - start) / DAY_MS) + 1); const state = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; const color = state?.color || '#6b7280'; return (
  • - beginDrag(e, issue, 'move') : undefined} + onClick={() => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + navigate(issueHref(issue.id)); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigate(issueHref(issue.id)); + } + }} + className={`absolute top-1.5 flex h-5 items-center overflow-hidden rounded-(--radius-md) text-[11px] font-medium text-white shadow-sm transition-opacity ${ + dragging ? 'opacity-90' : 'hover:opacity-80' + } ${canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'}`} style={{ - left: `${offset * DAY_PX + 2}px`, - width: `${span * DAY_PX - 4}px`, + left: `${offset * dayPx + 2}px`, + width: `${span * dayPx - 4}px`, backgroundColor: color, }} - title={`${issue.name} · ${issue.start_date} → ${issue.target_date}`} + title={`${issue.name} · ${fmtDay(start)} → ${fmtDay(end)}`} > - {issue.name} - + {canEdit && ( + beginDrag(e, issue, 'start')} + className="absolute left-0 top-0 h-full w-1.5 cursor-ew-resize bg-black/15 opacity-0 hover:opacity-100" + aria-hidden + /> + )} + {issue.name} + {canEdit && ( + beginDrag(e, issue, 'end')} + className="absolute right-0 top-0 h-full w-1.5 cursor-ew-resize bg-black/15 opacity-0 hover:opacity-100" + aria-hidden + /> + )} +
); })} @@ -234,6 +377,20 @@ export function IssueLayoutGantt({ ); } +// applyDragDelta resolves a drag's previewed [start, end] day timestamps, +// clamping so a resized edge never crosses the opposite edge. +function applyDragDelta( + mode: DragMode, + deltaDays: number, + origStart: number, + origEnd: number, +): { start: number; end: number } { + const shift = deltaDays * DAY_MS; + if (mode === 'move') return { start: origStart + shift, end: origEnd + shift }; + if (mode === 'start') return { start: Math.min(origStart + shift, origEnd), end: origEnd }; + return { start: origStart, end: Math.max(origEnd + shift, origStart) }; +} + function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } @@ -244,6 +401,17 @@ function parseDay(input: string): number | null { return startOfDay(new Date(t)).getTime(); } +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +// Format a day timestamp as YYYY-MM-DD (local components), matching how the +// calendar layout sends date patches. +function fmtDay(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + function fmtRange(start: number, end: number): string { const s = new Date(start); const e = new Date(end); diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index fa059716..8d357179 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -806,7 +806,9 @@ export function IssueListPage() { {layout === 'calendar' && ( )} - {layout === 'gantt' && } + {layout === 'gantt' && ( + + )} {layout === 'list' && (