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:
parent
4bc59316ce
commit
616a6674c8
1 changed files with 187 additions and 151 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue