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:
Dave 2026-09-17 12:35:52 +02:00
parent 0d4b62b0b4
commit d909a8bb34

View file

@ -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>
);
}
}