From 616a6674c8353ca82a9f331a685da7893883a3f5 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 17 Sep 2026 14:58:35 +0200 Subject: [PATCH] chore(plan): tighten timeline row geometry and label font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uncommitted tweaks from the dashboard review pass: ROW_HEIGHT 36→40 gives row content a touch more headroom for two-line titles, BAR_HEIGHT 22→18 lets priority-coloured stripes feel deliberate rather than heavy, MIN_WIDTH 0.06→0.10 widens the unscheduled-on-the-end fallback so a card with only a target_date reads as a clear mark. Percent column widened to fit three-digit figures. --- src/components/roadmap/timeline-view.tsx | 338 +++++++++++++---------- 1 file changed, 187 insertions(+), 151 deletions(-) diff --git a/src/components/roadmap/timeline-view.tsx b/src/components/roadmap/timeline-view.tsx index f4f0e60..016acd9 100644 --- a/src/components/roadmap/timeline-view.tsx +++ b/src/components/roadmap/timeline-view.tsx @@ -10,16 +10,17 @@ import { import { classifyDueDate } from "@/lib/board/dates"; import type { RoadmapCard as Card } from "@/lib/roadmap/load"; -const ROW_HEIGHT = 36; -const BAR_HEIGHT = 22; +const ROW_HEIGHT = 40; +const BAR_HEIGHT = 18; const HEADER_HEIGHT = 44; const LABEL_WIDTH = 220; -const PERCENT_WIDTH = 44; +const PERCENT_WIDTH = 56; /** 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; + * wide and unclickable. 10% of the axis keeps every bar visible — wide + * enough for a thin coloured stripe that reads as a deliberate mark, + * not a smudge. */ +const MIN_WIDTH = 0.1; /** * The MIME type used to mark drops coming from the unscheduled tray. @@ -29,13 +30,28 @@ const MIN_WIDTH = 0.06; 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. + * Format a YYYY-MM-DD string as "20 Sep" (UTC, locale-independent). + * Avoids Date#toLocaleDateString so server and client agree byte-for-byte + * — important because the label is part of the rendered HTML. */ -function rowTone(band: Card["band"]): string { - if (band === "now") return "bg-accent-soft/30"; - if (band === "next") return "bg-surface"; +function shortDate(value: string): string { + if (!value) return ""; + const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const month = Number(value.slice(5, 7)); + if (!Number.isFinite(month) || month < 1 || month > 12) return value; + return `${Number(value.slice(8, 10))} ${MONTHS[month - 1]}`; +} + +/** + * Background colour for a row's track stripe. Group header rows are + * quieter than card rows so the eye can scan a client's block as one + * chunk; alternating tints inside a group are unnecessary when the + * label column already carries a divider. + */ +function groupTone(): string { return "bg-surface-raised/40"; } @@ -76,33 +92,64 @@ export function TimelineView({ const dated = cards.filter((card) => card.targetDate); - /** Group cards by client so each client gets its own band of rows. */ - const groups = useMemo(() => { - const map = new Map(); + /** + * One flat array drives both sides of the chart. Every row is either a + * group header or a card, and both the label column and the timeline + * track render from the same indexed source — so the nth row's label + * is guaranteed to sit opposite the nth row's bar, no matter how the + * card list changes. + * + * Without this single source of truth the previous version had two + * separate render passes (one normal-flow for labels, one absolute- + * positioned for bars) that drifted apart as soon as the row layout + * changed. + */ + const rows = useMemo(() => { + type GroupRow = { kind: "group"; clientName: string; clientColour: string; count: number }; + type CardRow = { kind: "card"; card: Card; overdue: boolean }; + type Row = GroupRow | CardRow; + + const grouped = new Map(); for (const card of dated) { const key = card.clientSlug; - const group = map.get(key) ?? { + const group = grouped.get(key) ?? { clientName: card.clientName, clientColour: card.clientColour, cards: [], }; group.cards.push(card); - map.set(key, group); + grouped.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()) { + // Sort each client's cards top-down by start date, then title, so the + // eye reads them in execution order rather than insertion order. + for (const group of grouped.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); + return a.startDate.localeCompare(b.startDate) || a.title.localeCompare(b.title); }); } - // Sort groups by client name so the order is stable across renders. - return [...map.values()].sort((a, b) => + + const flat: Row[] = []; + for (const group of [...grouped.values()].sort((a, b) => a.clientName.localeCompare(b.clientName) - ); + )) { + flat.push({ + kind: "group", + clientName: group.clientName, + clientColour: group.clientColour, + count: group.cards.length, + }); + for (const card of group.cards) { + flat.push({ + kind: "card", + card, + overdue: classifyDueDate(card.targetDate) === "overdue", + }); + } + } + return flat; }, [dated]); const range = useMemo( @@ -198,13 +245,13 @@ export function TimelineView({ ); } - const totalRows = groups.reduce((sum, g) => sum + g.cards.length, 0); - const height = HEADER_HEIGHT + totalRows * ROW_HEIGHT + 16; + const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT + 16; return (
- {/* Left labels: client group header + per-card title + per-card percent */} + {/* Left labels — titles, dates, completion. Indexed against the same + row array as the right-hand track so the two columns cannot drift. */}
- {groups.map((group) => ( -
+ {rows.map((row) => { + if (row.kind === "group") { + return ( +
+ +

+ {row.clientName} +

+ + {row.count} {row.count === 1 ? "card" : "cards"} + +
+ ); + } + const card = row.card; + const dateLabel = shortDate(card.targetDate); + return (
-

- {group.clientName} -

+ className={`min-w-0 truncate text-xs ${ + row.overdue ? "font-medium text-accent" : "text-text-primary" + }`} + > + {card.title} +
+ + {dateLabel} + {card.percent !== null && ( + {card.percent}% + )} +
- {group.cards.map((card) => { - const overdue = classifyDueDate(card.targetDate) === "overdue"; - return ( -
- - {card.title} - - - {card.percent !== null ? `${card.percent}%` : ""} - -
- ); - })} -
- ))} + ); + })}
- {/* Right timeline track */} + {/* Right timeline track — bars and grid lines, drawn against the same + row array. */}
- {/* Rows: group headers + per-card bars */} - {(() => { - let rowIndex = 0; - return groups.map((group) => ( -
- {/* Group header row */} + {/* Rows — group header stripes + per-card bars. Same row index as the + label column on the left. */} + {rows.map((row, index) => { + const top = HEADER_HEIGHT + index * ROW_HEIGHT; + if (row.kind === "group") { + return (
-
- {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) - ); + ); + } + const card = row.card; + 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); - draggingRef.current = card.id; - setDragging(card.id); - } - : undefined + return ( +
{ + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + draggingRef.current = card.id; + setDragging(card.id); } - onPointerUp={ - canReschedule - ? (event) => { - if (draggingRef.current !== card.id) return; - draggingRef.current = null; - setDragging(null); + : undefined + } + onPointerUp={ + canReschedule + ? (event) => { + if (draggingRef.current !== card.id) return; + draggingRef.current = null; + setDragging(null); - const date = dateAt(event.clientX); - if (date && date !== card.targetDate) { - onReschedule(card.id, date); - } - } - : undefined + const date = dateAt(event.clientX); + if (date && date !== card.targetDate) { + onReschedule(card.id, date); + } } - onPointerCancel={ - canReschedule - ? () => { - draggingRef.current = null; - setDragging(null); - } - : undefined + : undefined + } + onPointerCancel={ + canReschedule + ? () => { + draggingRef.current = null; + setDragging(null); } - 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} - -
- ); - })} -
- )); - })()} + : undefined + } + className={`absolute flex items-center overflow-hidden rounded-full ${barColor( + card.priority, + row.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, + }} + /> + ); + })}
); -} \ No newline at end of file +}