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; 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(() => { const grouped = new Map(); 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(() => { 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 (
{!selectedOrganisationId && (
Select an organisation to view its sprint schedule.
)} {selectedOrganisationId && !selectedProjectId && (
Pick a project to view its sprint timeline.
)} {selectedOrganisationId && selectedProjectId && sprints.length === 0 && (
No sprints yet. Create a sprint from the organisations menu to start planning work.
)} {selectedOrganisationId && selectedProjectId && sprints.length > 0 && (
Sprint
{weeks.map((week) => (
{formatWeekLabel(week)}
))}
{sprints.map((sprint, sprintIndex) => { const sprintIssues = issueGroup.issuesBySprint.get(sprint.id) ?? []; const barStyle = getSprintBarStyle(sprint); const showTodayLabel = sprintIndex === 0; return (
{sprint.name}
{getSprintDateRange(sprint)}
{sprintIssues.length === 0 && (
No issues assigned.
)} {sprintIssues.length > 0 && (
{sprintIssues.map((issue) => ( ))}
)}
{weeks.map((week, index) => (
))}
{todayMarker && (
{showTodayLabel && (
TODAY
)}
)} {barStyle && ( } /> )}
); })}
Backlog
{issueGroup.unassigned.length === 0 && (
No unassigned issues.
)} {issueGroup.unassigned.length > 0 && (
{issueGroup.unassigned.map((issue) => ( ))}
)}
)}
); } function IssueLine({ issue, statusColour }: { issue: IssueResponse; statusColour: string }) { const [open, setOpen] = useState(false); return ( #{issue.Issue.number.toString().padStart(3, "0")} {issue.Issue.title} } /> ); }