Files
sprint/packages/frontend/src/pages/Timeline.tsx
2026-01-21 18:09:33 +00:00

404 lines
16 KiB
TypeScript

import {
DEFAULT_SPRINT_COLOUR,
DEFAULT_STATUS_COLOUR,
type IssueResponse,
type SprintRecord,
} from "@sprint/shared";
import { useEffect, useMemo, useState } from "react";
import { IssueModal } from "@/components/issue-modal";
import { useSelection } from "@/components/selection-provider";
import { SprintForm } from "@/components/sprint-form";
import StatusTag from "@/components/status-tag";
import TopBar from "@/components/top-bar";
import { BREATHING_ROOM } from "@/lib/layout";
import {
useIssues,
useOrganisations,
useProjects,
useSelectedOrganisation,
useSprints,
} from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
const DAY_MS = 24 * 60 * 60 * 1000;
const TIMELINE_LABEL_WIDTH = 240;
const WEEK_COLUMN_WIDTH = 140;
const addDays = (value: Date, days: number) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate() + days);
const toDate = (value: Date | string) => {
const parsed = value instanceof Date ? value : new Date(value);
return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
};
const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
const formatWeekLabel = (value: Date) =>
value.toLocaleDateString(undefined, { month: "short", day: "numeric" }).toUpperCase();
const formatTodayLabel = (value: Date) => {
const parts = new Intl.DateTimeFormat(undefined, { month: "short", day: "2-digit" }).formatToParts(value);
const month = parts.find((part) => part.type === "month")?.value ?? "";
const day = parts.find((part) => part.type === "day")?.value ?? "";
return `${day} ${month}`.trim().toUpperCase();
};
const getSprintDateRange = (sprint: SprintRecord) => {
return `${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`;
};
type IssueGroup = {
issuesBySprint: Map<number, IssueResponse[]>;
unassigned: IssueResponse[];
};
type TimelineRange = {
start: Date;
end: Date;
totalDays: number;
};
export default function Timeline() {
const { selectedOrganisationId, selectedProjectId, selectOrganisation, selectProject } = useSelection();
const { data: organisationsData = [] } = useOrganisations();
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { data: sprintsData = [] } = useSprints(selectedProjectId);
const { data: issuesData = [] } = useIssues(selectedProjectId);
const selectedOrganisation = useSelectedOrganisation();
const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
[organisationsData],
);
const projects = useMemo(
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
[projectsData],
);
const sprints = useMemo(
() =>
[...sprintsData].sort((a, b) => {
const aStart = a.startDate ? new Date(a.startDate).getTime() : null;
const bStart = b.startDate ? new Date(b.startDate).getTime() : null;
if (aStart != null && bStart != null) return aStart - bStart;
if (aStart == null && bStart == null) return a.name.localeCompare(b.name);
return aStart == null ? 1 : -1;
}),
[sprintsData],
);
const issueGroup = useMemo<IssueGroup>(() => {
const grouped = new Map<number, IssueResponse[]>();
const unassigned: IssueResponse[] = [];
for (const issue of issuesData) {
const sprintId = issue.Issue.sprintId;
if (!sprintId) {
unassigned.push(issue);
continue;
}
const current = grouped.get(sprintId);
if (current) current.push(issue);
else grouped.set(sprintId, [issue]);
}
for (const [sprintId, issues] of grouped.entries()) {
grouped.set(
sprintId,
[...issues].sort((a, b) => a.Issue.number - b.Issue.number),
);
}
return {
issuesBySprint: grouped,
unassigned: [...unassigned].sort((a, b) => a.Issue.number - b.Issue.number),
};
}, [issuesData]);
const timelineRange = useMemo<TimelineRange | null>(() => {
if (sprints.length === 0) return null;
const today = toDate(new Date());
let earliest = toDate(sprints[0].startDate);
let latest = toDate(sprints[0].endDate);
for (const sprint of sprints) {
const start = toDate(sprint.startDate);
const end = toDate(sprint.endDate);
if (start < earliest) earliest = start;
if (end > latest) latest = end;
}
const rangeStart = today;
const rangeEnd = addDays(today, 60);
const totalDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / DAY_MS);
return { start: rangeStart, end: rangeEnd, totalDays };
}, [sprints]);
const weeks = useMemo(() => {
if (!timelineRange) return [] as Date[];
const output: Date[] = [];
let cursor = new Date(timelineRange.start);
while (cursor <= timelineRange.end) {
output.push(new Date(cursor));
cursor = addDays(cursor, 7);
}
return output;
}, [timelineRange]);
useEffect(() => {
if (organisations.length === 0) return;
const selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
if (!selected) {
selectOrganisation(organisations[0]);
}
}, [organisations, selectedOrganisationId, selectOrganisation]);
useEffect(() => {
if (projects.length === 0) return;
const selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
if (!selected) {
selectProject(projects[0]);
}
}, [projects, selectedProjectId, selectProject]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const gridTemplateColumns = useMemo(() => {
if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH}px 1fr`;
return `${TIMELINE_LABEL_WIDTH}px repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`;
}, [weeks.length]);
const todayMarker = useMemo(() => {
if (!timelineRange) return null;
const today = toDate(new Date());
if (today < timelineRange.start || today > timelineRange.end) return null;
const dayOffset = (today.getTime() - timelineRange.start.getTime()) / DAY_MS;
const left = dayOffset * (WEEK_COLUMN_WIDTH / 7);
return { left: `${left}px`, label: formatTodayLabel(today) };
}, [timelineRange]);
const getSprintBarStyle = (sprint: SprintRecord) => {
if (!timelineRange) return null;
const start = toDate(sprint.startDate);
const end = addDays(toDate(sprint.endDate), 1);
const dayWidth = WEEK_COLUMN_WIDTH / 7;
const startOffset = (start.getTime() - timelineRange.start.getTime()) / DAY_MS;
const endOffset = (end.getTime() - timelineRange.start.getTime()) / DAY_MS;
const visibleStart = Math.max(0, startOffset);
const visibleEnd = Math.min(timelineRange.totalDays, endOffset);
const width = Math.max(visibleEnd - visibleStart, 0.25) * dayWidth;
return {
left: `${visibleStart * dayWidth}px`,
width: `${width}px`,
backgroundColor: sprint.color || DEFAULT_SPRINT_COLOUR,
};
};
return (
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
<TopBar />
<div className={`flex-1 flex flex-col gap-${BREATHING_ROOM}`}>
{!selectedOrganisationId && (
<div className="text-sm text-muted-foreground text-pretty">
Select an organisation to view its sprint schedule.
</div>
)}
{selectedOrganisationId && !selectedProjectId && (
<div className="text-sm text-muted-foreground text-pretty">
Pick a project to view its sprint timeline.
</div>
)}
{selectedOrganisationId && selectedProjectId && sprints.length === 0 && (
<div className="text-sm text-muted-foreground text-pretty">
No sprints yet. Create a sprint from the organisations menu to start planning work.
</div>
)}
{selectedOrganisationId && selectedProjectId && sprints.length > 0 && (
<div className="border">
<div className="overflow-x-auto">
<div
style={{
minWidth: `${TIMELINE_LABEL_WIDTH + weeks.length * WEEK_COLUMN_WIDTH}px`,
}}
>
<div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}>
<div
className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-background border-r sticky left-0 z-30`}
>
Sprint
</div>
{weeks.map((week) => (
<div
key={week.toISOString()}
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
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={`flex items-center justify-between gap-3`}>
<span
className="text-sm font-medium"
style={{
color: sprint.color || DEFAULT_SPRINT_COLOUR,
}}
>
{sprint.name}
</span>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{getSprintDateRange(sprint)}
</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
className={cn(`py-${BREATHING_ROOM} relative min-h-12`)}
style={{ gridColumn: "2 / -1" }}
>
<div
className="absolute inset-0 grid z-10 pointer-events-none"
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.left }}
>
<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>
)}
{barStyle && (
<SprintForm
mode="edit"
existingSprint={sprint}
sprints={sprints}
trigger={
<button
type="button"
aria-label={`Edit sprint ${sprint.name}`}
className="absolute top-1/2 z-0 h-4 rounded cursor-pointer"
style={barStyle}
title={`${sprint.name}: ${getSprintDateRange(sprint)}`}
/>
}
/>
)}
</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
className={cn(
`px-${BREATHING_ROOM} py-${BREATHING_ROOM} border-l text-xs text-muted-foreground`,
)}
style={{ gridColumn: "2 / -1" }}
></div>
</div>
</div>
</div>
</div>
)}
</div>
</main>
);
}
function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour: string }) {
const [open, setOpen] = useState(false);
return (
<IssueModal
issueData={issue}
open={open}
onOpenChange={setOpen}
trigger={
<button
type="button"
className={cn(
`flex items-center gap-${BREATHING_ROOM} text-xs text-muted-foreground`,
"hover:text-foreground cursor-pointer",
)}
>
<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>
</button>
}
/>
);
}