Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 225 additions & 41 deletions apps/web/src/components/work-item/layouts/IssueLayoutGantt.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(
Expand All @@ -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 };
Expand All @@ -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<DragState | null>(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 });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="space-y-3 px-4 py-3">
Expand All @@ -91,7 +186,7 @@ export function IssueLayoutGantt({
<ChevronRight className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-(--txt-primary)">
{fmtRange(window.start, window.end)}
{fmtRange(viewWindow.start, viewWindow.end)}
</h2>
<button
type="button"
Expand All @@ -100,9 +195,29 @@ export function IssueLayoutGantt({
>
Reset
</button>
<span className="ml-auto text-[11px] text-(--txt-tertiary)">
{dated.length} dated · {undated.length} undated
</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => setZoomIdx((z) => Math.max(0, z - 1))}
disabled={zoomIdx === 0}
className="inline-flex h-7 w-7 items-center justify-center rounded-(--radius-md) text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40"
aria-label="Zoom out"
>
<ZoomOut className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setZoomIdx((z) => Math.min(ZOOM_LEVELS.length - 1, z + 1))}
disabled={zoomIdx === ZOOM_LEVELS.length - 1}
className="inline-flex h-7 w-7 items-center justify-center rounded-(--radius-md) text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40"
aria-label="Zoom in"
>
<ZoomIn className="h-4 w-4" />
</button>
<span className="ml-1 text-[11px] text-(--txt-tertiary)">
{dated.length} dated · {undated.length} undated
</span>
</div>
</div>

{dated.length === 0 ? (
Expand Down Expand Up @@ -142,7 +257,7 @@ export function IssueLayoutGantt({
</div>

{/* Timeline */}
<div className="relative" style={{ width: `${totalDays * DAY_PX}px` }}>
<div className="relative" style={{ width: `${totalDays * dayPx}px` }}>
{/* Day-cell header */}
<div className="flex h-9 border-b border-(--border-subtle) bg-(--bg-surface-1)">
{days.map((ms, i) => {
Expand All @@ -152,7 +267,7 @@ export function IssueLayoutGantt({
<div
key={ms}
className="flex flex-col items-center justify-center border-r border-(--border-subtle) text-[10px] text-(--txt-tertiary)"
style={{ width: `${DAY_PX}px` }}
style={{ width: `${dayPx}px` }}
>
{isMonthStart && (
<span className="text-(--txt-secondary)">
Expand All @@ -169,34 +284,78 @@ export function IssueLayoutGantt({
{todayOffset >= 0 && todayOffset < totalDays && (
<div
className="pointer-events-none absolute top-9 z-10 h-[calc(100%-2.25rem)] w-px bg-(--txt-accent-primary) opacity-60"
style={{ left: `${todayOffset * DAY_PX + DAY_PX / 2}px` }}
style={{ left: `${todayOffset * dayPx + dayPx / 2}px` }}
aria-hidden
/>
)}

{/* Bars */}
<ul>
{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 (
<li key={issue.id} className="relative h-8 border-b border-(--border-subtle)">
<Link
to={issueHref(issue.id)}
className="absolute top-1.5 flex h-5 items-center overflow-hidden rounded-(--radius-md) px-2 text-[11px] font-medium text-white shadow-sm no-underline transition-opacity hover:opacity-80"
style={{
left: `${offset * DAY_PX + 2}px`,
width: `${span * DAY_PX - 4}px`,
backgroundColor: color,
}}
title={`${issue.name} · ${issue.start_date} → ${issue.target_date}`}
>
<span className="truncate">{issue.name}</span>
</Link>
{visible && (
<div
role="button"
tabIndex={0}
onPointerDown={canEdit ? (e) => 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 && (
<span
onPointerDown={(e) => 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
/>
)}
<span className="truncate px-2">{issue.name}</span>
{canEdit && (
<span
onPointerDown={(e) => 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
/>
)}
</div>
)}
</li>
);
})}
Expand Down Expand Up @@ -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());
}
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/pages/IssueListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,9 @@ export function IssueListPage() {
{layout === 'calendar' && (
<IssueLayoutCalendar {...layoutProps} onUpdateIssue={handleInlineUpdate} />
)}
{layout === 'gantt' && <IssueLayoutGantt {...layoutProps} />}
{layout === 'gantt' && (
<IssueLayoutGantt {...layoutProps} onUpdateIssue={handleInlineUpdate} />
)}
{layout === 'list' && (
<div className="border-t border-(--border-subtle) px-4 py-2.5">
<button
Expand Down
Loading