resizable sidebar

This commit is contained in:
Oliver Bryan
2026-01-21 23:42:41 +00:00
parent f16bb9d671
commit f4540f0e74

View File

@@ -10,6 +10,7 @@ import { useSelection } from "@/components/selection-provider";
import { SprintForm } from "@/components/sprint-form"; import { SprintForm } from "@/components/sprint-form";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import TopBar from "@/components/top-bar"; import TopBar from "@/components/top-bar";
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { BREATHING_ROOM } from "@/lib/layout"; import { BREATHING_ROOM } from "@/lib/layout";
import { import {
useIssues, useIssues,
@@ -21,8 +22,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const TIMELINE_LABEL_WIDTH = 240; const TIMELINE_LABEL_DEFAULT_SIZE = 420;
const WEEK_COLUMN_WIDTH = 140; const TIMELINE_LABEL_MIN_SIZE = 300;
const TIMELINE_LABEL_MAX_SIZE = "55%";
const WEEK_COLUMN_WIDTH = 160;
const addDays = (value: Date, days: number) => const addDays = (value: Date, days: number) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate() + days); new Date(value.getFullYear(), value.getMonth(), value.getDate() + days);
@@ -176,9 +179,9 @@ export default function Timeline() {
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const gridTemplateColumns = useMemo(() => { const weekGridTemplateColumns = useMemo(() => {
if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH}px 1fr`; if (weeks.length === 0) return "1fr";
return `${TIMELINE_LABEL_WIDTH}px repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`; return `repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`;
}, [weeks.length]); }, [weeks.length]);
const todayMarker = useMemo(() => { const todayMarker = useMemo(() => {
@@ -243,152 +246,139 @@ export default function Timeline() {
{selectedOrganisationId && selectedProjectId && sprints.length > 0 && ( {selectedOrganisationId && selectedProjectId && sprints.length > 0 && (
<div className="border"> <div className="border">
<div className="overflow-x-auto" ref={scrollRef}> <ResizablePanelGroup className="h-full">
<div <ResizablePanel
style={{ id="timeline-labels"
minWidth: `${TIMELINE_LABEL_WIDTH + weeks.length * WEEK_COLUMN_WIDTH}px`, defaultSize={TIMELINE_LABEL_DEFAULT_SIZE}
}} minSize={TIMELINE_LABEL_MIN_SIZE}
maxSize={TIMELINE_LABEL_MAX_SIZE}
className="border-r"
> >
<div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}> <div className="flex flex-col">
<div <div
className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-secondary border-r sticky left-0 z-30`} className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-secondary border-b`}
> >
Sprint Sprint
</div> </div>
{weeks.map((week) => ( {sprints.map((sprint) => {
<div const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? [];
key={week.toISOString()} return (
className={cn(
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs text-muted-foreground tabular-nums`,
"border-l",
)}
>
{formatWeekLabel(week)}
</div>
))}
</div>
{sprints.map((sprint, sprintIndex) => {
const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? [];
const barStyle = getSprintBarStyle(sprint);
const showTodayLabel = sprintIndex === 0;
return (
<div key={sprint.id} className="grid border-b" style={{ gridTemplateColumns }}>
<div <div
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-30 border-r sticky left-0`} key={sprint.id}
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background border-b`}
> >
<div className={`flex items-center justify-between gap-3`}> <SprintLabelContent sprint={sprint} sprintIssues={sprintIssues} statuses={statuses} />
<span
className="text-sm font-medium"
style={{
color: sprint.color || DEFAULT_SPRINT_COLOUR,
}}
>
{sprint.name}
</span>
<div className="text-[12px] text-muted-foreground tabular-nums">
{getSprintDateRange(sprint)}
</div>
</div>
{sprintIssues.length === 0 && (
<div className="text-xs text-muted-foreground text-pretty">No issues assigned.</div>
)}
{sprintIssues.length > 0 && (
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
{sprintIssues.map((issue) => (
<IssueLine
key={issue.Issue.id}
issue={issue}
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
/>
))}
</div>
)}
</div> </div>
<div );
className={cn(`py-${BREATHING_ROOM} relative min-h-12`)} })}
style={{ gridColumn: "2 / -1" }} <div
> className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background`}
>
<BacklogLabelContent issueGroup={issueGroup} statuses={statuses} />
</div>
</div>
</ResizablePanel>
<ResizablePanel id="timeline-grid">
<div className="overflow-x-auto" ref={scrollRef}>
<div style={{ minWidth: `${weeks.length * WEEK_COLUMN_WIDTH}px` }}>
<div
className="grid border-b bg-muted/20"
style={{ gridTemplateColumns: weekGridTemplateColumns }}
>
{weeks.map((week, index) => (
<div <div
className="absolute inset-0 grid z-10 pointer-events-none" key={week.toISOString()}
style={{ className={cn(
gridTemplateColumns: `repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`, `px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs text-muted-foreground tabular-nums`,
}} index === 0 ? "" : "border-l",
)}
> >
{weeks.map((week, index) => ( {formatWeekLabel(week)}
<div
key={`${week.toISOString()}-${sprint.id}`}
className={cn(index === 0 ? "" : "border-l")}
/>
))}
</div> </div>
{todayMarker && ( ))}
</div>
{sprints.map((sprint, sprintIndex) => {
const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? [];
const barStyle = getSprintBarStyle(sprint);
const showTodayLabel = sprintIndex === 0;
return (
<div key={sprint.id} className="relative border-b">
<div <div
className="absolute inset-y-0 z-10 pointer-events-none" className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} opacity-0 pointer-events-none`}
style={{ left: `${todayMarker.leftPx}px` }}
> >
<div <SprintLabelContent
className={cn("absolute inset-y-0 w-px bg-primary", showTodayLabel && "mt-1")} sprint={sprint}
sprintIssues={sprintIssues}
statuses={statuses}
/> />
{showTodayLabel && ( </div>
<div className="absolute -top-1.5"> <div className={cn(`absolute inset-0 py-${BREATHING_ROOM}`)}>
<span className="bg-primary px-1 py-0.5 text-[10px] font-semibold text-primary-foreground whitespace-nowrap"> <div
TODAY className="absolute inset-0 grid z-10 pointer-events-none"
</span> style={{
gridTemplateColumns: `repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`,
}}
>
{weeks.map((week, index) => (
<div
key={`${week.toISOString()}-${sprint.id}`}
className={cn(index === 0 ? "" : "border-l")}
/>
))}
</div>
{todayMarker && (
<div
className="absolute inset-y-0 z-10 pointer-events-none"
style={{ left: `${todayMarker.leftPx}px` }}
>
<div
className={cn(
"absolute inset-y-0 w-px bg-primary",
showTodayLabel && "mt-1",
)}
/>
{showTodayLabel && (
<div className="absolute -top-1.5">
<span className="bg-primary px-1 py-0.5 text-[10px] font-semibold text-primary-foreground whitespace-nowrap">
TODAY
</span>
</div>
)}
</div> </div>
)} )}
</div> {barStyle && (
)} <SprintForm
{barStyle && ( mode="edit"
<SprintForm existingSprint={sprint}
mode="edit" sprints={sprints}
existingSprint={sprint} trigger={
sprints={sprints} <button
trigger={ type="button"
<button aria-label={`Edit sprint ${sprint.name}`}
type="button" className="absolute top-1/2 -translate-y-1/2 z-0 h-4 rounded cursor-pointer"
aria-label={`Edit sprint ${sprint.name}`} style={barStyle}
className="absolute top-1/2 -translate-y-1/2 z-0 h-4 rounded cursor-pointer" title={`${sprint.name}: ${getSprintDateRange(sprint)}`}
style={barStyle} />
title={`${sprint.name}: ${getSprintDateRange(sprint)}`} }
/> />
} )}
/> </div>
)} </div>
);
})}
<div className="relative">
<div
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} opacity-0 pointer-events-none`}
>
<BacklogLabelContent issueGroup={issueGroup} statuses={statuses} />
</div> </div>
</div> </div>
);
})}
<div className="grid" style={{ gridTemplateColumns }}>
<div
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-30 border-r sticky left-0`}
>
<div className="text-sm font-medium">Backlog</div>
{issueGroup.unassigned.length === 0 && (
<div className="text-xs text-muted-foreground text-pretty">No unassigned issues.</div>
)}
{issueGroup.unassigned.length > 0 && (
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
{issueGroup.unassigned.map((issue) => (
<IssueLine
key={issue.Issue.id}
issue={issue}
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
/>
))}
</div>
)}
</div> </div>
<div
className={cn(
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} border-l text-xs text-muted-foreground`,
)}
style={{ gridColumn: "2 / -1" }}
></div>
</div> </div>
</div> </ResizablePanel>
</div> </ResizablePanelGroup>
</div> </div>
)} )}
</div> </div>
@@ -396,6 +386,74 @@ export default function Timeline() {
); );
} }
function SprintLabelContent({
sprint,
sprintIssues,
statuses,
}: {
sprint: SprintRecord;
sprintIssues: IssueResponse[];
statuses: Record<string, string>;
}) {
return (
<>
<div className="flex items-center justify-between gap-3">
<span
className="text-sm font-medium"
style={{
color: sprint.color || DEFAULT_SPRINT_COLOUR,
}}
>
{sprint.name}
</span>
<div className="text-[12px] text-muted-foreground tabular-nums">{getSprintDateRange(sprint)}</div>
</div>
{sprintIssues.length === 0 && (
<div className="text-xs text-muted-foreground text-pretty">No issues assigned.</div>
)}
{sprintIssues.length > 0 && (
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
{sprintIssues.map((issue) => (
<IssueLine
key={issue.Issue.id}
issue={issue}
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
/>
))}
</div>
)}
</>
);
}
function BacklogLabelContent({
issueGroup,
statuses,
}: {
issueGroup: IssueGroup;
statuses: Record<string, string>;
}) {
return (
<>
<div className="text-sm font-medium">Backlog</div>
{issueGroup.unassigned.length === 0 && (
<div className="text-xs text-muted-foreground text-pretty">No unassigned issues.</div>
)}
{issueGroup.unassigned.length > 0 && (
<div className={`flex flex-col gap-${BREATHING_ROOM}`}>
{issueGroup.unassigned.map((issue) => (
<IssueLine
key={issue.Issue.id}
issue={issue}
statusColour={statuses[issue.Issue.status] ?? DEFAULT_STATUS_COLOUR}
/>
))}
</div>
)}
</>
);
}
function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour: string }) { function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -412,8 +470,8 @@ function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour
"hover:text-foreground cursor-pointer", "hover:text-foreground cursor-pointer",
)} )}
> >
<span className="tabular-nums">{issue.Issue.number.toString().padStart(3, "0")}</span>
<StatusTag status={issue.Issue.status} colour={statusColour} className="text-[10px]" /> <StatusTag status={issue.Issue.status} colour={statusColour} className="text-[10px]" />
<span className="tabular-nums">#{issue.Issue.number.toString().padStart(3, "0")}</span>
<span className="truncate">{issue.Issue.title}</span> <span className="truncate">{issue.Issue.title}</span>
</button> </button>
} }