diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx index f4c7f734..000e228d 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx @@ -1,26 +1,40 @@ -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; +// Pointer movement (px) beyond which a gesture counts as a drag, not a click. +const DRAG_THRESHOLD_PX = 4; + +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 +43,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 +55,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 +80,91 @@ 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 [dragPreview, setDragPreview] = useState<{ + id: string; + mode: DragMode; + deltaDays: number; + } | null>(null); + // issueHref is captured in a ref so the pointer-up navigation doesn't force the + // window listeners to re-subscribe when the prop's identity changes. + const issueHrefRef = useRef(issueHref); + useEffect(() => { + issueHrefRef.current = issueHref; + }, [issueHref]); + + useEffect(() => { + const onMove = (e: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + // "moved" is a pixel threshold on the whole gesture, independent of the + // day-snap, so a small drag is still treated as a drag (not a click) and + // a sub-day drag doesn't navigate. + if (Math.abs(e.clientX - d.startClientX) > DRAG_THRESHOLD_PX) d.moved = true; + const deltaDays = Math.round((e.clientX - d.startClientX) / dayPx); + if (deltaDays !== d.deltaDays) { + d.deltaDays = deltaDays; + setDragPreview({ id: d.id, mode: d.mode, deltaDays }); + } + }; + const onUp = () => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + if (d.moved) { + // A real drag: commit the reschedule (if it landed on a different day). + 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); + } + } else { + // A click (no meaningful movement): open the work item. Handling it here, + // in the same gesture, avoids a lingering suppress-click flag. + navigate(issueHrefRef.current(d.id)); + } + setDragPreview(null); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + }, [dayPx, onUpdateIssue, navigate]); + + 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 +186,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 +257,7 @@ export function IssueLayoutGantt({ {/* Timeline */} -
+
{/* Day-cell header */}
{days.map((ms, i) => { @@ -152,7 +267,7 @@ export function IssueLayoutGantt({
{isMonthStart && ( @@ -169,7 +284,7 @@ export function IssueLayoutGantt({ {todayOffset >= 0 && todayOffset < totalDays && (
)} @@ -177,26 +292,70 @@ 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)); - const span = Math.max(1, Math.round((end - start) / DAY_MS) + 1); + 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; + } + // Intersect the bar with the visible window. A bar with no + // overlap renders as an empty row (keeping alignment with the + // sidebar) rather than being pinned to the first column. + const visible = end >= viewWindow.start && start <= viewWindow.end; + const clippedStart = Math.max(start, viewWindow.start); + const clippedEnd = Math.min(end, viewWindow.end); + const offset = Math.round((clippedStart - viewWindow.start) / DAY_MS); + const span = Math.max(1, Math.round((clippedEnd - clippedStart) / DAY_MS) + 1); const state = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; const color = state?.color || '#6b7280'; return (
  • - - {issue.name} - + {visible && ( +
    beginDrag(e, issue, 'move') : undefined} + onClick={canEdit ? undefined : () => 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 * dayPx + 2}px`, + width: `${span * dayPx - 4}px`, + backgroundColor: color, + }} + title={`${issue.name} · ${fmtDay(start)} → ${fmtDay(end)}`} + > + {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 +393,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 +417,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' && (