chore(plan): tighten timeline row geometry and label font

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.
This commit is contained in:
Dave 2026-09-17 14:58:35 +02:00
parent 4bc59316ce
commit 616a6674c8

View file

@ -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<string, { clientName: string; clientColour: string; cards: Card[] }>();
/**
* 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<string, { clientName: string; clientColour: string; cards: Card[] }>();
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 (
<div className="overflow-x-auto">
<div className="flex min-w-[720px]">
{/* 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. */}
<div
className="shrink-0"
style={{ width: LABEL_WIDTH + PERCENT_WIDTH }}
@ -217,47 +264,56 @@ export function TimelineView({
Work
</h2>
</div>
{groups.map((group) => (
<div key={group.clientName}>
{rows.map((row) => {
if (row.kind === "group") {
return (
<div
key={`g-${row.clientName}`}
className={`flex items-center gap-2 border-b border-border-subtle px-3 ${groupTone()}`}
style={{ height: ROW_HEIGHT }}
>
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: row.clientColour }}
/>
<h3 className="truncate text-[11px] font-semibold uppercase tracking-wide text-text-secondary">
{row.clientName}
</h3>
<span className="ml-auto tabular text-[11px] text-text-muted">
{row.count} {row.count === 1 ? "card" : "cards"}
</span>
</div>
);
}
const card = row.card;
const dateLabel = shortDate(card.targetDate);
return (
<div
className="flex items-center gap-2 border-b border-border-subtle px-3 py-1"
key={card.id}
className="flex items-center justify-between gap-2 border-b border-border-subtle/40 px-3 py-1"
style={{ height: ROW_HEIGHT }}
>
<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>
className={`min-w-0 truncate text-xs ${
row.overdue ? "font-medium text-accent" : "text-text-primary"
}`}
>
{card.title}
</span>
<span className="shrink-0 text-right text-[11px] leading-tight text-text-muted">
<span className="tabular block">{dateLabel}</span>
{card.percent !== null && (
<span className="tabular block">{card.percent}%</span>
)}
</span>
</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>
{/* Right timeline track */}
{/* Right timeline track — bars and grid lines, drawn against the same
row array. */}
<div
ref={trackRef}
className={`relative shrink-0 flex-1 transition-colors ${
@ -332,111 +388,91 @@ export function TimelineView({
Today
</span>
{/* Rows: group headers + per-card bars */}
{(() => {
let rowIndex = 0;
return groups.map((group) => (
<div key={group.clientName}>
{/* 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 (
<div
key={`g-track-${row.clientName}`}
aria-hidden
className={`border-b border-border-subtle ${rowTone(null)}`}
className={`border-b border-border-subtle ${groupTone()}`}
style={{
position: "absolute",
left: 0,
right: 0,
top: HEADER_HEIGHT + rowIndex * ROW_HEIGHT,
top,
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)
);
);
}
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 (
<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
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);
}
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,
}}
>
<span className="truncate font-medium">
{hasStart ? "" : "→ "}
{card.title}
</span>
</div>
);
})}
</div>
));
})()}
: 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,
}}
/>
);
})}
</div>
</div>
</div>
);
}
}