feat(plan): redo the timeline as a proper Gantt chart
Replaces the row-packed bar layout with the slide-style Gantt the user asked for: rows grouped by client, full date-range bars (start → target), priority-coloured fills, a today marker, percent indicators, and quarter/month header ticks. Single-day cards (no start_date set, which is the entire existing dataset) get a MIN_WIDTH floor so they read as substantial bars instead of zero-pixel dots. Tray entry point and drop-zone wiring are unchanged: an empty tray shows a quiet 'Every schedulable card is already on the timeline' placeholder; dropping a block from the tray still calls the same rescheduleCardAction. When cards start carrying start_date as well as target_date the bars will widen to the full date range automatically — the layout supports real duration without further code.
This commit is contained in:
parent
0d4b62b0b4
commit
d909a8bb34
1 changed files with 306 additions and 192 deletions
|
|
@ -5,18 +5,21 @@ import { useMemo, useRef, useState } from "react";
|
|||
import {
|
||||
datePosition,
|
||||
monthTicks,
|
||||
packRows,
|
||||
timelineRange,
|
||||
} from "@/lib/roadmap/timeline";
|
||||
import { classifyDueDate } from "@/lib/board/dates";
|
||||
import type { RoadmapCard as Card } from "@/lib/roadmap/load";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const BAR_HEIGHT = 18;
|
||||
const ROW_HEIGHT = 36;
|
||||
const BAR_HEIGHT = 22;
|
||||
const HEADER_HEIGHT = 44;
|
||||
const LABEL_WIDTH = 190;
|
||||
/** Smallest fraction of the axis an item may occupy — see packRows. */
|
||||
const MIN_WIDTH = 0.02;
|
||||
const LABEL_WIDTH = 220;
|
||||
const PERCENT_WIDTH = 44;
|
||||
/** Smallest fraction of the axis a bar may occupy. Cards with no start
|
||||
* date collapse to a single day; without a floor that reads as 0 pixels
|
||||
* wide and unclickable. 6% of the axis keeps every bar visible — enough
|
||||
* room for the card title to be readable. */
|
||||
const MIN_WIDTH = 0.06;
|
||||
|
||||
/**
|
||||
* The MIME type used to mark drops coming from the unscheduled tray.
|
||||
|
|
@ -25,6 +28,33 @@ const MIN_WIDTH = 0.02;
|
|||
*/
|
||||
const TRAY_MIME = "application/x-card-id";
|
||||
|
||||
/**
|
||||
* Background colour for a row in a given band. Matches the chart's quiet
|
||||
* striping so adjacent client groups read as separate but the eye can still
|
||||
* scan vertically.
|
||||
*/
|
||||
function rowTone(band: Card["band"]): string {
|
||||
if (band === "now") return "bg-accent-soft/30";
|
||||
if (band === "next") return "bg-surface";
|
||||
return "bg-surface-raised/40";
|
||||
}
|
||||
|
||||
function barColor(priority: Card["priority"], overdue: boolean): string {
|
||||
if (overdue) return "bg-accent text-accent-fg";
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
return "bg-accent text-accent-fg";
|
||||
case "high":
|
||||
return "bg-priority-high text-text-primary";
|
||||
case "medium":
|
||||
return "bg-brand text-brand-fg";
|
||||
case "low":
|
||||
return "bg-priority-low text-text-primary";
|
||||
default:
|
||||
return "bg-brand text-brand-fg";
|
||||
}
|
||||
}
|
||||
|
||||
export function TimelineView({
|
||||
cards,
|
||||
canReschedule,
|
||||
|
|
@ -35,6 +65,7 @@ export function TimelineView({
|
|||
onReschedule: (cardId: string, date: string) => void;
|
||||
}) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
/** Card id currently being pointer-dragged to reschedule. */
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
/** Read by the pointer handlers; `dragging` is only for the visual state. */
|
||||
const draggingRef = useRef<string | null>(null);
|
||||
|
|
@ -44,28 +75,57 @@ export function TimelineView({
|
|||
const [dropFraction, setDropFraction] = useState<number | null>(null);
|
||||
|
||||
const dated = cards.filter((card) => card.targetDate);
|
||||
const undated = cards.filter((card) => !card.targetDate);
|
||||
|
||||
const range = useMemo(() => timelineRange(dated), [dated]);
|
||||
const ticks = useMemo(() => monthTicks(range), [range]);
|
||||
const rows = useMemo(
|
||||
/** Group cards by client so each client gets its own band of rows. */
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, { clientName: string; clientColour: string; cards: Card[] }>();
|
||||
for (const card of dated) {
|
||||
const key = card.clientSlug;
|
||||
const group = map.get(key) ?? {
|
||||
clientName: card.clientName,
|
||||
clientColour: card.clientColour,
|
||||
cards: [],
|
||||
};
|
||||
group.cards.push(card);
|
||||
map.set(key, group);
|
||||
}
|
||||
// Sort cards within each group by start date so the timeline reads
|
||||
// top-down in execution order. Cards with no start date sort last.
|
||||
for (const group of map.values()) {
|
||||
group.cards.sort((a, b) => {
|
||||
if (!a.startDate && !b.startDate) return a.title.localeCompare(b.title);
|
||||
if (!a.startDate) return 1;
|
||||
if (!b.startDate) return -1;
|
||||
return a.startDate.localeCompare(b.startDate);
|
||||
});
|
||||
}
|
||||
// Sort groups by client name so the order is stable across renders.
|
||||
return [...map.values()].sort((a, b) =>
|
||||
a.clientName.localeCompare(b.clientName)
|
||||
);
|
||||
}, [dated]);
|
||||
|
||||
const range = useMemo(
|
||||
() =>
|
||||
packRows(
|
||||
timelineRange(
|
||||
dated.map((c) => ({
|
||||
id: c.id,
|
||||
startDate: c.startDate,
|
||||
targetDate: c.targetDate,
|
||||
})),
|
||||
range,
|
||||
MIN_WIDTH
|
||||
}))
|
||||
),
|
||||
[dated, range]
|
||||
[dated]
|
||||
);
|
||||
|
||||
const byId = useMemo(() => new Map(cards.map((c) => [c.id, c])), [cards]);
|
||||
const ticks = useMemo(() => monthTicks(range), [range]);
|
||||
const todayPosition = datePosition(new Date().toISOString().slice(0, 10), range);
|
||||
|
||||
/** Pointer x within the track, as a date. Snapped to whole days. */
|
||||
/**
|
||||
* Pointer x within the track, as a date. Snapped to whole days. Pure — the
|
||||
* returned string is what we hand to rescheduleCardAction. Defined as a
|
||||
* function on the render closure so both the drop handler below and the
|
||||
* drag-to-reschedule handler further down use the same arithmetic.
|
||||
*/
|
||||
function dateAt(clientX: number): string | null {
|
||||
const element = trackRef.current;
|
||||
if (!element) return null;
|
||||
|
|
@ -125,204 +185,258 @@ export function TimelineView({
|
|||
onReschedule(cardId, date);
|
||||
}
|
||||
|
||||
const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT + 12;
|
||||
if (dated.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-tile)] border-2 border-dashed border-border-subtle px-4 py-12 text-center text-text-muted">
|
||||
<h2 className="text-base font-medium text-text-primary">
|
||||
No scheduled work yet
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-sm">
|
||||
Drag a block from the tray onto this area to give it a target date.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalRows = groups.reduce((sum, g) => sum + g.cards.length, 0);
|
||||
const height = HEADER_HEIGHT + totalRows * ROW_HEIGHT + 16;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex min-w-[720px]">
|
||||
<div style={{ width: LABEL_WIDTH }} className="shrink-0" />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex min-w-[720px]">
|
||||
{/* Left labels: client group header + per-card title + per-card percent */}
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{ width: LABEL_WIDTH + PERCENT_WIDTH }}
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={`relative flex-1 transition-colors ${
|
||||
dropHover ? "bg-brand-soft/40" : ""
|
||||
}`}
|
||||
style={{ height }}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className="border-b border-border-subtle px-3 pb-1"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
{/* Empty-state hint: nothing scheduled yet, so drop a block from
|
||||
the tray to start the timeline. */}
|
||||
{dated.length === 0 && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
Work
|
||||
</h2>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.clientName}>
|
||||
<div
|
||||
className="absolute inset-x-6 top-1/2 -translate-y-1/2 rounded-[var(--radius-tile)] border-2 border-dashed border-border-subtle px-4 py-6 text-center text-xs text-text-muted"
|
||||
className="flex items-center gap-2 border-b border-border-subtle px-3 py-1"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
Drag a block from the tray onto the timeline to schedule it.
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: group.clientColour }}
|
||||
/>
|
||||
<h3 className="truncate text-[11px] font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{group.clientName}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{group.cards.map((card) => {
|
||||
const overdue = classifyDueDate(card.targetDate) === "overdue";
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-border-subtle/40 px-3"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<span
|
||||
className={`truncate text-xs ${
|
||||
overdue ? "font-medium text-accent" : "text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{card.title}
|
||||
</span>
|
||||
<span className="tabular shrink-0 text-[11px] text-text-muted">
|
||||
{card.percent !== null ? `${card.percent}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quarter and month header */}
|
||||
{ticks.map((tick) => (
|
||||
<div
|
||||
key={tick.date}
|
||||
className="absolute top-0 text-[10px] text-text-muted"
|
||||
style={{ left: `${tick.position * 100}%` }}
|
||||
>
|
||||
{tick.quarterStart && (
|
||||
<span className="block font-semibold text-text-secondary">
|
||||
{tick.quarter}
|
||||
</span>
|
||||
)}
|
||||
<span className="block">{tick.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ticks.map((tick) => (
|
||||
<div
|
||||
key={`grid-${tick.date}`}
|
||||
aria-hidden
|
||||
className={`absolute w-px ${
|
||||
tick.quarterStart ? "bg-border-strong" : "bg-border-subtle"
|
||||
}`}
|
||||
style={{
|
||||
left: `${tick.position * 100}%`,
|
||||
top: HEADER_HEIGHT - 8,
|
||||
height: height - HEADER_HEIGHT + 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Today. The most orienting element on the chart. */}
|
||||
{/* Right timeline track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={`relative shrink-0 flex-1 transition-colors ${
|
||||
dropHover ? "bg-brand-soft/40" : ""
|
||||
}`}
|
||||
style={{ height, minWidth: 480 }}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{/* Empty-state hint: dated cards exist, but the track is here for
|
||||
dropping MORE cards from the tray. Only show when there's room
|
||||
AND when dragging. */}
|
||||
{dropHover && dropFraction !== null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute w-px bg-accent"
|
||||
className="absolute top-0 w-0.5 bg-brand"
|
||||
style={{
|
||||
left: `${todayPosition * 100}%`,
|
||||
left: `${dropFraction * 100}%`,
|
||||
top: HEADER_HEIGHT - 16,
|
||||
height: height - HEADER_HEIGHT + 16,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute -translate-x-1/2 rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-medium text-accent-fg"
|
||||
style={{ left: `${todayPosition * 100}%`, top: HEADER_HEIGHT - 26 }}
|
||||
)}
|
||||
|
||||
{/* Quarter and month header */}
|
||||
{ticks.map((tick) => (
|
||||
<div
|
||||
key={tick.date}
|
||||
className="absolute top-0 text-[10px] text-text-muted"
|
||||
style={{ left: `${tick.position * 100}%` }}
|
||||
>
|
||||
Today
|
||||
</span>
|
||||
{tick.quarterStart && (
|
||||
<span className="block font-semibold text-text-secondary">
|
||||
{tick.quarter}
|
||||
</span>
|
||||
)}
|
||||
<span className="block">{tick.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drop indicator: where a tray block would land if released. */}
|
||||
{dropHover && dropFraction !== null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0 w-0.5 bg-brand"
|
||||
style={{
|
||||
left: `${dropFraction * 100}%`,
|
||||
top: HEADER_HEIGHT - 16,
|
||||
height: height - HEADER_HEIGHT + 16,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Grid lines */}
|
||||
{ticks.map((tick) => (
|
||||
<div
|
||||
key={`grid-${tick.date}`}
|
||||
aria-hidden
|
||||
className={`absolute w-px ${
|
||||
tick.quarterStart ? "bg-border-strong" : "bg-border-subtle"
|
||||
}`}
|
||||
style={{
|
||||
left: `${tick.position * 100}%`,
|
||||
top: HEADER_HEIGHT - 8,
|
||||
height: height - HEADER_HEIGHT + 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{rows.map((row, rowIndex) =>
|
||||
row.map((item) => {
|
||||
const card = byId.get(item.id);
|
||||
if (!card) return null;
|
||||
{/* Today. The most orienting element on the chart. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute w-px bg-accent"
|
||||
style={{
|
||||
left: `${todayPosition * 100}%`,
|
||||
top: HEADER_HEIGHT - 16,
|
||||
height: height - HEADER_HEIGHT + 16,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute -translate-x-1/2 rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-medium text-accent-fg"
|
||||
style={{ left: `${todayPosition * 100}%`, top: HEADER_HEIGHT - 26 }}
|
||||
>
|
||||
Today
|
||||
</span>
|
||||
|
||||
const overdue = classifyDueDate(card.targetDate) === "overdue";
|
||||
const top = HEADER_HEIGHT + rowIndex * ROW_HEIGHT;
|
||||
{/* Rows: group headers + per-card bars */}
|
||||
{(() => {
|
||||
let rowIndex = 0;
|
||||
return groups.map((group) => (
|
||||
<div key={group.clientName}>
|
||||
{/* Group header row */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={`border-b border-border-subtle ${rowTone(null)}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: HEADER_HEIGHT + rowIndex * ROW_HEIGHT,
|
||||
height: ROW_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="border-b border-border-subtle text-[10px] uppercase tracking-wide text-text-muted"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
top: HEADER_HEIGHT + rowIndex * ROW_HEIGHT + 6,
|
||||
height: ROW_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{group.cards.length} {group.cards.length === 1 ? "card" : "cards"}
|
||||
</div>
|
||||
{group.cards.map((card) => {
|
||||
const top = HEADER_HEIGHT + rowIndex * ROW_HEIGHT;
|
||||
rowIndex += 1;
|
||||
const overdue =
|
||||
classifyDueDate(card.targetDate) === "overdue";
|
||||
const hasStart = Boolean(card.startDate);
|
||||
const start = hasStart
|
||||
? datePosition(card.startDate, range)
|
||||
: datePosition(card.targetDate, range);
|
||||
const end = Math.max(
|
||||
start + MIN_WIDTH,
|
||||
datePosition(card.targetDate, range)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role={canReschedule ? "button" : undefined}
|
||||
tabIndex={canReschedule ? 0 : undefined}
|
||||
title={`${card.title} — ${card.clientName}`}
|
||||
onPointerDown={
|
||||
canReschedule
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
// The ref, not the state, is what the pointerup
|
||||
// handler reads. State updates are asynchronous, so
|
||||
// a handler comparing against `dragging` can still
|
||||
// see the pre-drag value and return early — the drag
|
||||
// would then never commit, with no error anywhere.
|
||||
draggingRef.current = item.id;
|
||||
setDragging(item.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={
|
||||
canReschedule
|
||||
? (event) => {
|
||||
if (draggingRef.current !== item.id) return;
|
||||
draggingRef.current = null;
|
||||
setDragging(null);
|
||||
|
||||
const date = dateAt(event.clientX);
|
||||
if (date && date !== card.targetDate.slice(0, 10)) {
|
||||
onReschedule(item.id, date);
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
role={canReschedule ? "button" : undefined}
|
||||
tabIndex={canReschedule ? 0 : undefined}
|
||||
title={`${card.title} — ${card.clientName} → ${card.targetDate}`}
|
||||
onPointerDown={
|
||||
canReschedule
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
draggingRef.current = card.id;
|
||||
setDragging(card.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerCancel={
|
||||
canReschedule
|
||||
? () => {
|
||||
// A cancelled pointer must clear the drag, or the
|
||||
// bar stays visually held and the next click on it
|
||||
// is swallowed.
|
||||
draggingRef.current = null;
|
||||
setDragging(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`absolute flex items-center overflow-hidden rounded-full px-2 text-[11px] ${
|
||||
overdue ? "bg-accent text-accent-fg" : "bg-brand text-brand-fg"
|
||||
} ${canReschedule ? "cursor-ew-resize" : ""} ${
|
||||
dragging === item.id ? "opacity-70" : ""
|
||||
}`}
|
||||
style={{
|
||||
left: `${item.start * 100}%`,
|
||||
width: `${Math.max(item.end - item.start, MIN_WIDTH) * 100}%`,
|
||||
top: top + (ROW_HEIGHT - BAR_HEIGHT) / 2,
|
||||
height: BAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{card.title}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={
|
||||
canReschedule
|
||||
? (event) => {
|
||||
if (draggingRef.current !== card.id) return;
|
||||
draggingRef.current = null;
|
||||
setDragging(null);
|
||||
|
||||
{/* The faint track behind every row. Without it a sparse chart
|
||||
reads as broken rather than as unscheduled. */}
|
||||
{rows.map((_, rowIndex) => (
|
||||
<div
|
||||
key={`track-${rowIndex}`}
|
||||
aria-hidden
|
||||
className="absolute left-0 right-0 -z-10 rounded-full bg-surface-raised"
|
||||
style={{
|
||||
top: HEADER_HEIGHT + rowIndex * ROW_HEIGHT + (ROW_HEIGHT - BAR_HEIGHT) / 2,
|
||||
height: BAR_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
const date = dateAt(event.clientX);
|
||||
if (date && date !== card.targetDate) {
|
||||
onReschedule(card.id, date);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerCancel={
|
||||
canReschedule
|
||||
? () => {
|
||||
draggingRef.current = null;
|
||||
setDragging(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`absolute flex items-center overflow-hidden rounded-full px-2 text-[11px] ${
|
||||
barColor(card.priority, overdue)
|
||||
} ${canReschedule ? "cursor-ew-resize" : ""} ${
|
||||
dragging === card.id ? "opacity-70" : ""
|
||||
}`}
|
||||
style={{
|
||||
left: `${start * 100}%`,
|
||||
width: `${(end - start) * 100}%`,
|
||||
top: top + (ROW_HEIGHT - BAR_HEIGHT) / 2,
|
||||
height: BAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{hasStart ? "" : "→ "}
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{undated.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
No date set
|
||||
<span className="tabular ml-2 font-normal text-text-muted">
|
||||
{undated.length}
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{undated.map((card) => (
|
||||
<li
|
||||
key={card.id}
|
||||
className="rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-[11px] text-text-secondary"
|
||||
>
|
||||
{card.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue