fixed alignment errors when scrolling to the side

This commit is contained in:
Oliver Bryan
2026-01-21 17:59:34 +00:00
parent 5a5e40659c
commit 147d273dbc

View File

@@ -21,7 +21,8 @@ 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 = "240px"; const TIMELINE_LABEL_WIDTH = 240;
const WEEK_COLUMN_WIDTH = 140;
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);
@@ -56,7 +57,7 @@ type IssueGroup = {
type TimelineRange = { type TimelineRange = {
start: Date; start: Date;
end: Date; end: Date;
durationMs: number; totalDays: number;
}; };
export default function Timeline() { export default function Timeline() {
@@ -133,9 +134,9 @@ export default function Timeline() {
const rangeStart = today; const rangeStart = today;
const rangeEnd = addDays(today, 60); const rangeEnd = addDays(today, 60);
const durationMs = rangeEnd.getTime() - rangeStart.getTime() + DAY_MS; const totalDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / DAY_MS);
return { start: rangeStart, end: rangeEnd, durationMs }; return { start: rangeStart, end: rangeEnd, totalDays };
}, [sprints]); }, [sprints]);
const weeks = useMemo(() => { const weeks = useMemo(() => {
@@ -168,28 +169,32 @@ export default function Timeline() {
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const gridTemplateColumns = useMemo(() => { const gridTemplateColumns = useMemo(() => {
if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH} 1fr`; if (weeks.length === 0) return `${TIMELINE_LABEL_WIDTH}px 1fr`;
return `${TIMELINE_LABEL_WIDTH} repeat(${weeks.length}, minmax(140px, 1fr))`; return `${TIMELINE_LABEL_WIDTH}px repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`;
}, [weeks.length]); }, [weeks.length]);
const todayMarker = useMemo(() => { const todayMarker = useMemo(() => {
if (!timelineRange) return null; if (!timelineRange) return null;
const today = toDate(new Date()); const today = toDate(new Date());
if (today < timelineRange.start || today > timelineRange.end) return null; if (today < timelineRange.start || today > timelineRange.end) return null;
const left = ((today.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; const dayOffset = (today.getTime() - timelineRange.start.getTime()) / DAY_MS;
return { left: `${left}%`, label: formatTodayLabel(today) }; const left = dayOffset * (WEEK_COLUMN_WIDTH / 7);
return { left: `${left}px`, label: formatTodayLabel(today) };
}, [timelineRange]); }, [timelineRange]);
const getSprintBarStyle = (sprint: SprintRecord) => { const getSprintBarStyle = (sprint: SprintRecord) => {
if (!timelineRange) return null; if (!timelineRange) return null;
const start = toDate(sprint.startDate); const start = toDate(sprint.startDate);
const end = addDays(toDate(sprint.endDate), 1); const end = addDays(toDate(sprint.endDate), 1);
const left = ((start.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; const dayWidth = WEEK_COLUMN_WIDTH / 7;
const right = ((end.getTime() - timelineRange.start.getTime()) / timelineRange.durationMs) * 100; const startOffset = (start.getTime() - timelineRange.start.getTime()) / DAY_MS;
const width = Math.max(right - left, 1); 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 { return {
left: `${left}%`, left: `${visibleStart * dayWidth}px`,
width: `${width}%`, width: `${width}px`,
backgroundColor: sprint.color || DEFAULT_SPRINT_COLOUR, backgroundColor: sprint.color || DEFAULT_SPRINT_COLOUR,
}; };
}; };
@@ -220,7 +225,11 @@ 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"> <div className="overflow-x-auto">
<div className="min-w-[720px]"> <div
style={{
minWidth: `${TIMELINE_LABEL_WIDTH + weeks.length * WEEK_COLUMN_WIDTH}px`,
}}
>
<div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}> <div className="grid border-b bg-muted/20" style={{ gridTemplateColumns }}>
<div <div
className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-background border-r`} className={`px-${BREATHING_ROOM} py-${BREATHING_ROOM} text-xs font-medium text-muted-foreground bg-background border-r`}
@@ -278,14 +287,19 @@ export default function Timeline() {
)} )}
</div> </div>
<div <div
className={cn(`py-${BREATHING_ROOM} relative min-h-12`, "border-l")} className={cn(`py-${BREATHING_ROOM} relative min-h-12`)}
style={{ gridColumn: "2 / -1" }} style={{ gridColumn: "2 / -1" }}
> >
<div className="absolute inset-0 flex z-10 pointer-events-none"> <div
className="absolute inset-0 grid z-20 pointer-events-none"
style={{
gridTemplateColumns: `repeat(${weeks.length}, ${WEEK_COLUMN_WIDTH}px)`,
}}
>
{weeks.map((week, index) => ( {weeks.map((week, index) => (
<div <div
key={`${week.toISOString()}-${sprint.id}`} key={`${week.toISOString()}-${sprint.id}`}
className={cn("flex-1", index === 0 ? "" : "border-l")} className={cn(index === 0 ? "" : "border-l")}
/> />
))} ))}
</div> </div>
@@ -313,7 +327,7 @@ export default function Timeline() {
<button <button
type="button" type="button"
aria-label={`Edit sprint ${sprint.name}`} aria-label={`Edit sprint ${sprint.name}`}
className="absolute top-1/2 z-0 h-4 rounded border border-foreground/10 cursor-pointer" className="absolute top-1/2 z-0 h-4 rounded cursor-pointer"
style={barStyle} style={barStyle}
title={`${sprint.name}: ${getSprintDateRange(sprint)}`} title={`${sprint.name}: ${getSprintDateRange(sprint)}`}
/> />
@@ -329,7 +343,7 @@ export default function Timeline() {
<div <div
className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`} className={`px-${BREATHING_ROOM} pt-0.5 py-${BREATHING_ROOM} flex flex-col gap-${BREATHING_ROOM} bg-background relative z-20 border-r`}
> >
<div className="text-sm font-medium text-muted-foreground">Backlog</div> <div className="text-sm font-medium">Backlog</div>
{issueGroup.unassigned.length === 0 && ( {issueGroup.unassigned.length === 0 && (
<div className="text-xs text-muted-foreground text-pretty">No unassigned issues.</div> <div className="text-xs text-muted-foreground text-pretty">No unassigned issues.</div>
)} )}