diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 4bf0492..cf8ae0f 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -13,6 +13,7 @@ import Landing from "@/pages/Landing"; import Login from "@/pages/Login"; import NotFound from "@/pages/NotFound"; import Test from "@/pages/Test"; +import Timeline from "@/pages/Timeline"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -44,6 +45,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> + + + + } + /> } /> diff --git a/packages/frontend/src/pages/Timeline.tsx b/packages/frontend/src/pages/Timeline.tsx new file mode 100644 index 0000000..de97e79 --- /dev/null +++ b/packages/frontend/src/pages/Timeline.tsx @@ -0,0 +1,409 @@ +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 = "240px"; + +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; + durationMs: 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 durationMs = rangeEnd.getTime() - rangeStart.getTime() + DAY_MS; + + return { start: rangeStart, end: rangeEnd, durationMs }; + }, [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} 1fr`; + return `${TIMELINE_LABEL_WIDTH} repeat(${weeks.length}, minmax(140px, 1fr))`; + }, [weeks.length]); + + const todayMarker = useMemo(() => { + if (!timelineRange) return null; + const today = toDate(new Date()); + if (today < timelineRange.start || today > timelineRange.end) return null; + const left = ((today.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; + return { left: `${left}%`, 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 left = ((start.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; + const right = ((end.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; + const width = Math.max(right - left, 1); + return { + left: `${left}%`, + width: `${width}%`, + 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 && ( +
+ + {todayMarker.label} + +
+ )} +
+ )} + {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} + + } + /> + ); +}