feat(plan): ProjectSection component

One project's Gantt section: header with brand-colour top border,
label column with indented subtask labels, timeline track with
month labels + W1..W4 ticks + today marker, and per-row bars
reusing GanttBar. Flat row array drives both columns so labels
and bars cannot drift apart. Footer counts undated cards and
subtasks.
This commit is contained in:
Dave 2026-09-21 16:16:05 +02:00
parent 644843ce0c
commit 812af63e66

View file

@ -0,0 +1,282 @@
"use client";
import { useMemo } from "react";
import type { ReactNode } from "react";
import { GanttBar } from "@/components/roadmap/gantt-bar";
import {
datePosition,
monthTicks,
weekTicks,
type TimelineRange,
} from "@/lib/roadmap/timeline";
import type { PlanCard, PlanSection } from "@/lib/roadmap/load";
const ROW_HEIGHT = 28;
const BAR_HEIGHT_CARD = 14;
const BAR_HEIGHT_SUBTASK = 10;
const HEADER_HEIGHT = 56;
const SUBTASK_INSET = 18;
const LABEL_WIDTH = 220;
const LABEL_PAD_X = 12;
type Row =
| { kind: "card"; planCard: PlanCard }
| { kind: "subtask"; planCard: PlanCard; subtaskId: string }
| { kind: "undated"; planCard: PlanCard };
interface ProjectSectionProps {
section: PlanSection;
range: TimelineRange;
}
export function ProjectSection({ section, range }: ProjectSectionProps) {
const { project, client, cards } = section;
/** One flat array drives both sides of the chart so a label and its
* bar can never drift apart. */
const rows = useMemo<Row[]>(() => {
const out: Row[] = [];
for (const planCard of cards) {
const isDated = Boolean(planCard.startDate || planCard.targetDate);
if (isDated) {
out.push({ kind: "card", planCard });
for (const subtask of planCard.datedSubtasks) {
out.push({ kind: "subtask", planCard, subtaskId: subtask.id });
}
} else {
out.push({ kind: "undated", planCard });
}
}
return out;
}, [cards]);
const months = monthTicks(range);
const weeks = weekTicks(range);
const today = new Date().toISOString().slice(0, 10);
const todayFraction = datePosition(today, range);
const trackHeight = HEADER_HEIGHT + Math.max(1, rows.length) * ROW_HEIGHT + 12;
const undatedCardCount = cards.filter(
(c) => !c.startDate && !c.targetDate
).length;
const undatedSubtaskTotal = cards.reduce(
(n, c) => n + c.undatedSubtaskCount,
0
);
return (
<section
aria-label={`${project.name} timeline`}
className="rounded-[var(--radius-tile)] border border-[var(--border-subtle)] bg-[var(--surface)]"
>
{/* Section header */}
<header
className="flex items-baseline justify-between border-b border-[var(--border-subtle)] px-4 py-2.5"
style={{ borderTop: `2px solid ${client.brandColor}` }}
>
<h3 className="text-base font-semibold text-[var(--text-primary)]">
{project.name}
</h3>
<p className="text-xs text-[var(--text-muted)]">
{client.name} · {cards.length} card{cards.length === 1 ? "" : "s"}
</p>
</header>
<div className="overflow-x-auto">
<div className="flex min-w-[640px]">
{/* Label column */}
<div
className="shrink-0 border-r border-[var(--border-subtle)]"
style={{ width: LABEL_WIDTH }}
>
<div style={{ height: HEADER_HEIGHT }} aria-hidden />
{rows.map((row, idx) => (
<div
key={labelKey(row, idx)}
className="flex items-center truncate text-sm"
style={{
height: ROW_HEIGHT,
paddingLeft:
row.kind === "subtask"
? SUBTASK_INSET + LABEL_PAD_X
: LABEL_PAD_X,
}}
>
{labelFor(row)}
</div>
))}
</div>
{/* Timeline track */}
<div className="relative flex-1" style={{ height: trackHeight }}>
{/* Month + week header */}
<div
className="absolute left-0 right-0 top-0 border-b border-[var(--border-subtle)]"
style={{ height: HEADER_HEIGHT }}
>
{months.map((tick) => (
<span
key={`m-${tick.date}`}
className="absolute top-1 text-[11px] font-medium text-[var(--text-secondary)]"
style={{ left: `${tick.position * 100}%` }}
>
{tick.label}
</span>
))}
{weeks.map((tick) => (
<span
key={`w-${tick.date}`}
className="absolute bottom-1 text-[10px] tabular-nums text-[var(--text-muted)]"
style={{ left: `${tick.position * 100}%` }}
>
{weekLabel(tick.date)}
</span>
))}
</div>
{/* Today marker */}
{todayFraction > 0 && todayFraction < 1 && (
<>
<div
aria-hidden
className="absolute w-px bg-[var(--accent)]"
style={{
left: `${todayFraction * 100}%`,
top: HEADER_HEIGHT - 6,
height: trackHeight - HEADER_HEIGHT + 6,
}}
/>
<span
className="absolute rounded-full bg-[var(--accent)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent-fg)]"
style={{
left: `calc(${todayFraction * 100}% + 4px)`,
top: HEADER_HEIGHT - 18,
}}
>
Today
</span>
</>
)}
{/* Rows — each row is its own absolutely-positioned band so the
bar and the matching label cell line up vertically. */}
{rows.map((row, idx) => {
const rowTop = HEADER_HEIGHT + idx * ROW_HEIGHT;
if (row.kind === "undated") {
return (
<div
key={`u-${row.planCard.id}`}
className="absolute left-3 right-3 flex items-center text-xs italic text-[var(--text-muted)]"
style={{ top: rowTop, height: ROW_HEIGHT }}
>
— not scheduled —
</div>
);
}
if (row.kind === "subtask") {
const subtask = row.planCard.datedSubtasks.find(
(s) => s.id === row.subtaskId
);
if (!subtask) return null;
return (
<div
key={`s-row-${subtask.id}`}
className="absolute left-0 right-0"
style={{ top: rowTop, height: ROW_HEIGHT }}
>
<GanttBar
href={row.planCard.href}
startDate={subtask.startDate}
targetDate={subtask.targetDate}
range={range}
color={client.brandColor}
insetLeft={SUBTASK_INSET}
height={BAR_HEIGHT_SUBTASK}
title={`${subtask.title} — ${subtask.startDate || "?"} → ${subtask.targetDate || "?"}`}
strike={subtask.done}
/>
</div>
);
}
return (
<div
key={`c-row-${row.planCard.id}`}
className="absolute left-0 right-0"
style={{ top: rowTop, height: ROW_HEIGHT }}
>
<GanttBar
href={row.planCard.href}
startDate={row.planCard.startDate}
targetDate={row.planCard.targetDate}
range={range}
color={client.brandColor}
insetLeft={0}
height={BAR_HEIGHT_CARD}
title={`${row.planCard.title} — ${row.planCard.startDate || "?"} → ${row.planCard.targetDate || "?"}`}
/>
</div>
);
})}
</div>
</div>
</div>
{/* Footer */}
<footer className="flex items-center justify-between border-t border-[var(--border-subtle)] px-4 py-2 text-xs text-[var(--text-muted)]">
<span>
{undatedCardCount} card{undatedCardCount === 1 ? "" : "s"} not scheduled
{" · "}
{undatedSubtaskTotal} subtask{undatedSubtaskTotal === 1 ? "" : "s"}{" "}
not scheduled
</span>
</footer>
</section>
);
}
function labelKey(row: Row, idx: number): string {
if (row.kind === "subtask") return `s-${row.planCard.id}-${row.subtaskId}-${idx}`;
if (row.kind === "undated") return `u-${row.planCard.id}-${idx}`;
return `c-${row.planCard.id}`;
}
/** "W1..W4" labels: which week-of-month this Monday belongs to. */
function weekLabel(isoDate: string): string {
if (!isoDate) return "";
const day = Number(isoDate.slice(8, 10));
const week = Math.ceil(day / 7);
return `W${Math.min(4, Math.max(1, week))}`;
}
function labelFor(row: Row): ReactNode {
if (row.kind === "subtask") {
const subtask = row.planCard.datedSubtasks.find((s) => s.id === row.subtaskId);
return (
<span
className={`truncate text-[var(--text-secondary)] ${subtask?.done ? "line-through" : ""}`}
>
{subtask?.title ?? ""}
</span>
);
}
if (row.kind === "undated") {
return (
<span className="truncate text-[var(--text-primary)]">{row.planCard.title}</span>
);
}
return (
<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate font-medium text-[var(--text-primary)]">
{row.planCard.title}
</span>
{row.planCard.assignee && (
<span className="shrink-0 text-xs text-[var(--text-muted)]">
{row.planCard.assignee}
</span>
)}
</span>
);
}