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:
parent
644843ce0c
commit
812af63e66
1 changed files with 282 additions and 0 deletions
282
src/components/roadmap/project-section.tsx
Normal file
282
src/components/roadmap/project-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue