diff --git a/src/components/roadmap/timeline-view.tsx b/src/components/roadmap/timeline-view.tsx index 9d6a3a2..f4f0e60 100644 --- a/src/components/roadmap/timeline-view.tsx +++ b/src/components/roadmap/timeline-view.tsx @@ -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(null); + /** Card id currently being pointer-dragged to reschedule. */ const [dragging, setDragging] = useState(null); /** Read by the pointer handlers; `dragging` is only for the visual state. */ const draggingRef = useRef(null); @@ -44,28 +75,57 @@ export function TimelineView({ const [dropFraction, setDropFraction] = useState(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(); + 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 ( +
+

+ No scheduled work yet +

+

+ Drag a block from the tray onto this area to give it a target date. +

+
+ ); + } + + const totalRows = groups.reduce((sum, g) => sum + g.cards.length, 0); + const height = HEADER_HEIGHT + totalRows * ROW_HEIGHT + 16; return ( -
-
-
-
- +
+
+ {/* Left labels: client group header + per-card title + per-card percent */} +
- {/* Empty-state hint: nothing scheduled yet, so drop a block from - the tray to start the timeline. */} - {dated.length === 0 && ( +

+ Work +

+
+ {groups.map((group) => ( +
- Drag a block from the tray onto the timeline to schedule it. + +

+ {group.clientName} +

- )} + {group.cards.map((card) => { + const overdue = classifyDueDate(card.targetDate) === "overdue"; + return ( +
+ + {card.title} + + + {card.percent !== null ? `${card.percent}%` : ""} + +
+ ); + })} +
+ ))} +
- {/* Quarter and month header */} - {ticks.map((tick) => ( -
- {tick.quarterStart && ( - - {tick.quarter} - - )} - {tick.label} -
- ))} - - {ticks.map((tick) => ( -
- ))} - - {/* Today. The most orienting element on the chart. */} + {/* Right timeline track */} +
+ {/* 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 && (
- ( +
- Today - + {tick.quarterStart && ( + + {tick.quarter} + + )} + {tick.label} +
+ ))} - {/* Drop indicator: where a tray block would land if released. */} - {dropHover && dropFraction !== null && ( -
- )} + {/* Grid lines */} + {ticks.map((tick) => ( +
+ ))} - {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. */} +
+ + Today + - 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) => ( +
+ {/* Group header row */} +
+
+ {group.cards.length} {group.cards.length === 1 ? "card" : "cards"} +
+ {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 ( -
{ - 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 ( +
{ + 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, - }} - > - {card.title} -
- ); - }) - )} + : 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) => ( -
- ))} -
+ 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, + }} + > + + {hasStart ? "" : "→ "} + {card.title} + +
+ ); + })} +
+ )); + })()}
- - {undated.length > 0 && ( -
-

- No date set - - {undated.length} - -

-
    - {undated.map((card) => ( -
  • - {card.title} -
  • - ))} -
-
- )}
); -} +} \ No newline at end of file