From 96688afeff873ce7ed5508e502378fd9e29709f4 Mon Sep 17 00:00:00 2001 From: JSdotNet Date: Fri, 2 Oct 2026 15:29:22 +0200 Subject: [PATCH] Snap a resized roadmap edge to the axis columns On a graduated axis a resize grip moved a whole week at a time, even across the day columns around today. A resized edge now snaps to the column lines the axis is ruled with - a day near today, then a week, a month and a quarter further out - by pointer and by Shift/Alt+arrow alike. Moves, and resizes on a plain all-quarter window, keep snapping to the week. The track publishes its column lines and each grip its edge, so the script counts the lines crossed and .NET resolves the same lines to dates; the preview and the drop stay one value. Co-Authored-By: Claude Opus 5.5 --- .../Roadmap/RoadmapBar.cs | 4 +- .../Roadmap/RoadmapChange.cs | 60 +++++++++++- .../Roadmap/RoadmapTimeline.razor | 18 +++- .../Roadmap/RoadmapTimelineBar.razor | 4 +- .../Roadmap/RoadmapWindow.cs | 9 ++ .../wwwroot/components.js | 31 +++++- .../Components/Pages/RoadmapPage.razor | 2 +- .../RoadmapGraduatedAxisTests.cs | 97 +++++++++++++++++++ 8 files changed, 215 insertions(+), 10 deletions(-) diff --git a/src/Core/Backlog.UI.Components/Roadmap/RoadmapBar.cs b/src/Core/Backlog.UI.Components/Roadmap/RoadmapBar.cs index 711094aa7..416cdc4be 100644 --- a/src/Core/Backlog.UI.Components/Roadmap/RoadmapBar.cs +++ b/src/Core/Backlog.UI.Components/Roadmap/RoadmapBar.cs @@ -158,8 +158,8 @@ public sealed record RoadmapLink(string FromId, string ToId); /// double-clicked it — how far they dragged. /// /// Proposed, not created — like , what goes there and -/// whether it may is the host's. Both ends snap to whole weeks, because every -/// gesture on a timeline snaps to the week. +/// whether it may is the host's. Both ends snap to whole weeks, as a move does; +/// only a resized edge on a graduated axis snaps finer, to the columns. /// /// /// The row it is on. A band's padding reports the lane drawn diff --git a/src/Core/Backlog.UI.Components/Roadmap/RoadmapChange.cs b/src/Core/Backlog.UI.Components/Roadmap/RoadmapChange.cs index 7e4d805d0..bf275b633 100644 --- a/src/Core/Backlog.UI.Components/Roadmap/RoadmapChange.cs +++ b/src/Core/Backlog.UI.Components/Roadmap/RoadmapChange.cs @@ -59,12 +59,17 @@ public sealed record RoadmapChange( /// The row it would land on. Only consulted for a /// ; an edge cannot change rows. /// Which day a week begins on here. + /// The column boundaries an edge snaps to instead of + /// the week — on a graduated axis — + /// in which case counts boundaries crossed rather + /// than weeks. Null keeps the week. A move always counts weeks. public static RoadmapChange? For( RoadmapBar bar, RoadmapDrag kind, int weekSteps, string? rowId, - DayOfWeek weekStart) + DayOfWeek weekStart, + IReadOnlyList? boundaries = null) { // A locked bar refuses everything, except the end of one that offers it: started // work has a start that is a fact and an end the reader may overrule. @@ -73,11 +78,64 @@ public sealed record RoadmapChange( return kind switch { RoadmapDrag.Move => Moved(bar, weekSteps, rowId ?? bar.RowId, weekStart), + RoadmapDrag.ResizeStart when boundaries is { Count: > 0 } => StartStepped(bar, weekSteps, boundaries), RoadmapDrag.ResizeStart => StartMoved(bar, weekSteps, weekStart), + _ when boundaries is { Count: > 0 } => EndStepped(bar, weekSteps, boundaries), _ => EndMoved(bar, weekSteps, weekStart) }; } + private static RoadmapChange? StartStepped(RoadmapBar bar, int steps, IReadOnlyList boundaries) + { + if (steps == 0) return null; + + var start = Step(bar.Start, steps, boundaries); + + // Past its own end: the last line still inside the bar, as the week grid + // clamps to the week the end is in — or its last day, when no line is. + if (start > bar.End) start = boundaries.LastOrDefault(boundary => boundary > bar.Start && boundary <= bar.End, bar.End); + + return start == bar.Start ? null : new RoadmapChange(bar.Id, bar.RowId, start, bar.End, RoadmapDrag.ResizeStart); + } + + private static RoadmapChange? EndStepped(RoadmapBar bar, int steps, IReadOnlyList boundaries) + { + if (steps == 0) return null; + + // An end is the last day inclusive, so its edge is the day after it, and the + // boundary it lands on is the day after its new end. + var end = Step(bar.End.AddDays(1), steps, boundaries).AddDays(-1); + + if (end < bar.Start) end = boundaries.FirstOrDefault(boundary => boundary > bar.Start && boundary <= bar.End, bar.Start.AddDays(1)).AddDays(-1); + + return end == bar.End ? null : new RoadmapChange(bar.Id, bar.RowId, bar.Start, end, RoadmapDrag.ResizeEnd); + } + + /// + /// The boundary lines away from an edge, counting only + /// the lines strictly past it in that direction — so an edge sitting between two + /// lines reaches the nearer one first, either way. Running off the axis stops at + /// its last line rather than leaving the grid. + /// + public static DateOnly Step(DateOnly edge, int steps, IReadOnlyList boundaries) + { + if (steps > 0) + { + var ahead = boundaries.Where(boundary => boundary > edge).ToList(); + + return ahead.Count == 0 ? edge : ahead[Math.Min(steps, ahead.Count) - 1]; + } + + if (steps < 0) + { + var behind = boundaries.Where(boundary => boundary < edge).ToList(); + + return behind.Count == 0 ? edge : behind[Math.Max(0, behind.Count + steps)]; + } + + return edge; + } + private static RoadmapChange? Moved(RoadmapBar bar, int weekSteps, string rowId, DayOfWeek weekStart) { var sameRow = string.Equals(rowId, bar.RowId, StringComparison.Ordinal); diff --git a/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimeline.razor b/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimeline.razor index 8c00f89de..19f36fed6 100644 --- a/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimeline.razor +++ b/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimeline.razor @@ -166,7 +166,7 @@ role="region" tabindex="0" aria-label="@ScrollLabel"> -
+
@foreach (var column in Span.Columns) @@ -1074,7 +1074,8 @@ StateHasChanged(); } - /// How far the drag has got, in whole weeks and whole rows. Called + /// How far the drag has got, in whole weeks and whole rows — or, for an + /// edge on a graduated axis, in column lines crossed. Called /// only when that answer changes, which is why it is cheap enough to redraw /// the whole chart — arrows included — on every one. [JSInvokable] @@ -1392,9 +1393,20 @@ { if (!Is(ActiveId, barId) || Bar(barId) is not { } bar) return null; - return RoadmapChange.For(bar, _kind, _steps, Landing(bar) ?? bar.RowId, WeekStart); + return RoadmapChange.For(bar, _kind, _steps, Landing(bar) ?? bar.RowId, WeekStart, ResizeBoundaries); } + /// The lines a resized edge snaps to when the axis is graduated — its own + /// columns, so an edge near today moves by the day — or null on a plain window, + /// whose quarter columns are too coarse and whose edges snap to the week. + private IReadOnlyList? ResizeBoundaries => Span.IsGraduated ? Span.ColumnBoundaries : null; + + /// Where each of is drawn, for the script + /// to count the lines a dragged edge has crossed; null leaves it counting weeks. + private string? ResizeSnaps => ResizeBoundaries is { } boundaries + ? string.Join(' ', boundaries.Select(boundary => RoadmapGeometry.N(Geometry.XFor(boundary)))) + : null; + /// /// Which row a move would land on, or null when it would land somewhere it /// cannot go. diff --git a/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimelineBar.razor b/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimelineBar.razor index 1abd8c4e6..5f27d16df 100644 --- a/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimelineBar.razor +++ b/src/Core/Backlog.UI.Components/Roadmap/RoadmapTimelineBar.razor @@ -95,13 +95,13 @@ hundred of them between a reader and the row below. *@ @if (Resizable) { - + } @* A locked bar whose end is still the reader's to overrule draws that one edge. *@ @if (Resizable || EndOnlyResizable) { - + } @* A pointer affordance like the edges, and for the same reason no tab stop: the diff --git a/src/Core/Backlog.UI.Components/Roadmap/RoadmapWindow.cs b/src/Core/Backlog.UI.Components/Roadmap/RoadmapWindow.cs index d65714f35..45f4d8e05 100644 --- a/src/Core/Backlog.UI.Components/Roadmap/RoadmapWindow.cs +++ b/src/Core/Backlog.UI.Components/Roadmap/RoadmapWindow.cs @@ -65,6 +65,15 @@ private RoadmapWindow(IReadOnlyList columns) /// width everywhere along the track. public bool IsGraduated { get; } + /// + /// Every day a column begins on, in order, then the day after the last column — + /// the lines the axis is ruled with, and what a resized edge of a + /// window snaps to: a day near today, a week a little + /// further out, a month and a quarter beyond. A plain window's columns are + /// quarters, too coarse to resize by, so its edges keep snapping to the week. + /// + public IReadOnlyList ColumnBoundaries => [.. Columns.Select(column => column.Start), End.AddDays(1)]; + /// How many days the window spans, counting both ends. Never zero, /// so it is always safe to divide by. public int TotalDays => End.DayNumber - Start.DayNumber + 1; diff --git a/src/Core/Backlog.UI.Components/wwwroot/components.js b/src/Core/Backlog.UI.Components/wwwroot/components.js index 2cea9c67b..061d615ce 100644 --- a/src/Core/Backlog.UI.Components/wwwroot/components.js +++ b/src/Core/Backlog.UI.Components/wwwroot/components.js @@ -3497,6 +3497,26 @@ is converted here at the moment of the drag so a reader who has zoomed their text still moves a bar one week per week's width on their screen. */ + /* + How many column lines an edge dragged from edgeRem to pointerRem has + crossed, signed: the line nearest the pointer, counted from the edge + the way RoadmapChange.Step counts back — only lines strictly past the + edge, so an edge between two lines reaches the nearer one first. + */ + const backlogRoadmapLinesCrossed = (lines, edgeRem, pointerRem) => { + const near = 0.01; + let nearest = lines[0]; + for (const line of lines) { + if (Math.abs(line - pointerRem) < Math.abs(nearest - pointerRem)) nearest = line; + } + + if (Math.abs(nearest - edgeRem) < near) return 0; + + return nearest > edgeRem + ? lines.filter((line) => line > edgeRem + near && line <= nearest + near).length + : -lines.filter((line) => line < edgeRem - near && line >= nearest - near).length; + }; + const backlogRoadmapTimelines = new Map(); const backlogRoadmapScrollers = new Map(); @@ -3728,6 +3748,13 @@ // Read per grip: on a graduated axis a week is wider near today // than a year out, so the grip says what a week is where it sits. drag.weekRem = Number(grip.dataset.roadmapWeekRem) || weekRem; + // An edge on a graduated axis snaps to the columns it is ruled + // with — a day near today, a month a quarter out — so it counts + // the lines it crosses instead of weeks. The track lists where + // they are drawn; a plain axis lists none and keeps the week. + const snaps = grip.dataset.roadmapGrip !== 'move' ? element.dataset.roadmapSnaps : null; + drag.snaps = snaps ? snaps.split(' ').map(Number) : null; + drag.edgeRem = Number(grip.dataset.roadmapEdgeRem) || 0; drag.steps = 0; drag.rows = 0; @@ -3763,7 +3790,9 @@ if (!drag.active || event.pointerId !== drag.pointerId) return; const rem = backlogRootFontSize(); - const steps = Math.round((event.clientX - drag.startX) / rem / drag.weekRem); + const steps = drag.snaps + ? backlogRoadmapLinesCrossed(drag.snaps, drag.edgeRem, drag.edgeRem + (event.clientX - drag.startX) / rem) + : Math.round((event.clientX - drag.startX) / rem / drag.weekRem); // An edge has no row to land on, so vertical travel while // resizing is a wobble in the reader's hand, not an instruction. diff --git a/src/Harness/Backlog.UI.Storybook/Components/Pages/RoadmapPage.razor b/src/Harness/Backlog.UI.Storybook/Components/Pages/RoadmapPage.razor index 4769e1c00..13fc215ff 100644 --- a/src/Harness/Backlog.UI.Storybook/Components/Pages/RoadmapPage.razor +++ b/src/Harness/Backlog.UI.Storybook/Components/Pages/RoadmapPage.razor @@ -28,7 +28,7 @@ Nothing here is a category the library invented — TARGETS, OPERATIONS and PROD diff --git a/tests/Backlog.UI.Components.UnitTests/RoadmapGraduatedAxisTests.cs b/tests/Backlog.UI.Components.UnitTests/RoadmapGraduatedAxisTests.cs index a47da9e9f..e3ad2e567 100644 --- a/tests/Backlog.UI.Components.UnitTests/RoadmapGraduatedAxisTests.cs +++ b/tests/Backlog.UI.Components.UnitTests/RoadmapGraduatedAxisTests.cs @@ -296,6 +296,103 @@ public void AMeasuredScroller_OpensOnLastWeek_WithOlderHistoryToTheLeft() Assert.True((double)scroll.Arguments[1]! > 0, "History before this week sits to the left of where the chart opens."); } + // --- Resizing snaps to the columns ---------------------------------------- + + [Fact] + public void TheColumnBoundaries_AreEveryColumnStart_AndTheDayAfterTheLast() + { + var window = Window(new DateOnly(2026, 10, 1), new DateOnly(2026, 12, 15)); + var lines = window.ColumnBoundaries; + + Assert.Equal(window.Start, lines[0]); + Assert.Equal(window.End.AddDays(1), lines[^1]); + Assert.Equal(window.Columns.Count + 1, lines.Count); + + // A line every day around today, every week beyond, then a month. + Assert.Contains(new DateOnly(2026, 9, 24), lines); + Assert.Contains(new DateOnly(2026, 9, 25), lines); + Assert.DoesNotContain(new DateOnly(2026, 10, 6), lines); + Assert.Contains(new DateOnly(2026, 10, 12), lines); + Assert.Contains(new DateOnly(2026, 11, 1), lines); + Assert.DoesNotContain(new DateOnly(2026, 11, 2), lines); + } + + [Fact] + public void AnEdgeAmongTheDays_MovesADayAStep() + { + var lines = Window(new DateOnly(2026, 12, 15)).ColumnBoundaries; + var bar = new RoadmapBar("d", "row", "D", new DateOnly(2026, 9, 21), new DateOnly(2026, 9, 25)); + + var later = RoadmapChange.For(bar, RoadmapDrag.ResizeEnd, 1, null, DayOfWeek.Monday, lines); + var sooner = RoadmapChange.For(bar, RoadmapDrag.ResizeEnd, -2, null, DayOfWeek.Monday, lines); + var earlier = RoadmapChange.For(bar, RoadmapDrag.ResizeStart, -1, null, DayOfWeek.Monday, lines); + + Assert.Equal(new DateOnly(2026, 9, 26), later!.End); + Assert.Equal(new DateOnly(2026, 9, 23), sooner!.End); + Assert.Equal(new DateOnly(2026, 9, 20), earlier!.Start); + Assert.Equal(bar.End, earlier.End); + } + + [Fact] + public void AnEdgeAmongTheMonths_MovesToTheNextMonth_AndAnEdgeBetweenLinesReachesTheNearerOneFirst() + { + var lines = Window(new DateOnly(2027, 2, 15)).ColumnBoundaries; + + // Ends on Friday 20 November, mid-month: one step out is the end of + // November, two the end of December. + var bar = new RoadmapBar("c", "row", "C", new DateOnly(2026, 11, 2), new DateOnly(2026, 11, 20)); + + Assert.Equal(new DateOnly(2026, 11, 30), RoadmapChange.For(bar, RoadmapDrag.ResizeEnd, 1, null, DayOfWeek.Monday, lines)!.End); + Assert.Equal(new DateOnly(2026, 12, 31), RoadmapChange.For(bar, RoadmapDrag.ResizeEnd, 2, null, DayOfWeek.Monday, lines)!.End); + + // A start on the 2nd steps back to the 1st, not a whole month further. + Assert.Equal(new DateOnly(2026, 11, 1), RoadmapChange.For(bar, RoadmapDrag.ResizeStart, -1, null, DayOfWeek.Monday, lines)!.Start); + } + + [Fact] + public void AnEdgePulledPastTheOppositeEdge_ClampsToTheShortestBarTheColumnsAllow() + { + var lines = Window(new DateOnly(2027, 2, 15)).ColumnBoundaries; + var bar = new RoadmapBar("c", "row", "C", new DateOnly(2026, 11, 2), new DateOnly(2026, 11, 20)); + + var end = RoadmapChange.For(bar, RoadmapDrag.ResizeEnd, -3, null, DayOfWeek.Monday, lines); + var start = RoadmapChange.For(bar, RoadmapDrag.ResizeStart, 3, null, DayOfWeek.Monday, lines); + + Assert.Equal(bar.Start, end!.End); + Assert.Equal(bar.End, start!.Start); + } + + [Fact] + public void ShiftAndAnArrow_OnAGraduatedAxis_MovesTheEndByAColumn() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + RoadmapChange? reported = null; + + var view = context.Render(parameters => parameters + .Add(timeline => timeline.Groups, [new RoadmapGroup("g", "g", [new RoadmapRow("row", "Row")])]) + .Add(timeline => timeline.Bars, [new RoadmapBar("d", "row", "D", new DateOnly(2026, 9, 21), new DateOnly(2026, 9, 25))]) + .Add(timeline => timeline.Today, Today) + .Add(timeline => timeline.Graduated, true) + .Add(timeline => timeline.TestId, "rm") + .Add(timeline => timeline.OnBarChanged, (RoadmapChange change) => reported = change)); + + // The script reads where the lines are from the track, and where the edge is + // from its grip. + Assert.False(string.IsNullOrEmpty(view.Find(".roadmap-timeline__track").GetAttribute("data-roadmap-snaps"))); + Assert.False(string.IsNullOrEmpty(view.Find("[data-roadmap-grip='end']").GetAttribute("data-roadmap-edge-rem"))); + + var bar = view.Find("[data-testid='rm-bar-d'] .roadmap-bar__body"); + + bar.KeyDown(new KeyboardEventArgs { Key = " " }); + bar.KeyDown(new KeyboardEventArgs { Key = "ArrowRight", ShiftKey = true }); + bar.KeyDown(new KeyboardEventArgs { Key = " " }); + + Assert.NotNull(reported); + Assert.Equal(RoadmapDrag.ResizeEnd, reported.Kind); + Assert.Equal(new DateOnly(2026, 9, 26), reported.End); + } + private static IRenderedComponent RenderPlan(BunitContext context, params RoadmapBar[] extra) => context.Render(parameters => parameters .Add(timeline => timeline.Groups,