Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Core/Backlog.UI.Components/Roadmap/RoadmapBar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ public sealed record RoadmapLink(string FromId, string ToId);
/// double-clicked it — how far they dragged.
/// <para>
/// Proposed, not created — like <see cref="RoadmapChange"/>, 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.
/// </para>
/// </summary>
/// <param name="RowId">The row it is on. A band's padding reports the lane drawn
Expand Down
60 changes: 59 additions & 1 deletion src/Core/Backlog.UI.Components/Roadmap/RoadmapChange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,17 @@ public sealed record RoadmapChange(
/// <param name="rowId">The row it would land on. Only consulted for a
/// <see cref="RoadmapDrag.Move"/>; an edge cannot change rows.</param>
/// <param name="weekStart">Which day a week begins on here.</param>
/// <param name="boundaries">The column boundaries an edge snaps to instead of
/// the week — <see cref="RoadmapWindow.ColumnBoundaries"/> on a graduated axis —
/// in which case <paramref name="weekSteps"/> counts boundaries crossed rather
/// than weeks. Null keeps the week. A move always counts weeks.</param>
public static RoadmapChange? For(
RoadmapBar bar,
RoadmapDrag kind,
int weekSteps,
string? rowId,
DayOfWeek weekStart)
DayOfWeek weekStart,
IReadOnlyList<DateOnly>? 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.
Expand All @@ -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<DateOnly> 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<DateOnly> 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);
}

/// <summary>
/// The boundary <paramref name="steps"/> 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.
/// </summary>
public static DateOnly Step(DateOnly edge, int steps, IReadOnlyList<DateOnly> 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);
Expand Down
18 changes: 15 additions & 3 deletions src/Core/Backlog.UI.Components/Roadmap/RoadmapTimeline.razor
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@
role="region"
tabindex="0"
aria-label="@ScrollLabel">
<div @ref="_track" class="roadmap-timeline__track" style="width: @Rem(Geometry.TrackWidthRem)">
<div @ref="_track" class="roadmap-timeline__track" style="width: @Rem(Geometry.TrackWidthRem)" data-roadmap-snaps="@ResizeSnaps">

<div class="roadmap-timeline__axis" data-testid="@Suffix("axis")">
@foreach (var column in Span.Columns)
Expand Down Expand Up @@ -1074,7 +1074,8 @@
StateHasChanged();
}

/// <summary>How far the drag has got, in whole weeks and whole rows. Called
/// <summary>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.</summary>
[JSInvokable]
Expand Down Expand Up @@ -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);
}

/// <summary>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.</summary>
private IReadOnlyList<DateOnly>? ResizeBoundaries => Span.IsGraduated ? Span.ColumnBoundaries : null;

/// <summary>Where each of <see cref="ResizeBoundaries"/> is drawn, for the script
/// to count the lines a dragged edge has crossed; null leaves it counting weeks.</summary>
private string? ResizeSnaps => ResizeBoundaries is { } boundaries
? string.Join(' ', boundaries.Select(boundary => RoadmapGeometry.N(Geometry.XFor(boundary))))
: null;

/// <summary>
/// Which row a move would land on, or null when it would land somewhere it
/// cannot go.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,13 @@
hundred of them between a reader and the row below. *@
@if (Resizable)
{
<span class="roadmap-bar__grip roadmap-bar__grip--start" data-roadmap-grip="start" data-roadmap-week-rem="@RoadmapGeometry.N(Geometry.WeekWidthAt(Bar.Start))" aria-hidden="true"></span>
<span class="roadmap-bar__grip roadmap-bar__grip--start" data-roadmap-grip="start" data-roadmap-week-rem="@RoadmapGeometry.N(Geometry.WeekWidthAt(Bar.Start))" data-roadmap-edge-rem="@RoadmapGeometry.N(Geometry.XFor(Bar.Start))" aria-hidden="true"></span>
}

@* A locked bar whose end is still the reader's to overrule draws that one edge. *@
@if (Resizable || EndOnlyResizable)
{
<span class="roadmap-bar__grip roadmap-bar__grip--end" data-roadmap-grip="end" data-roadmap-week-rem="@RoadmapGeometry.N(Geometry.WeekWidthAt(Bar.End.AddDays(-6)))" aria-hidden="true"></span>
<span class="roadmap-bar__grip roadmap-bar__grip--end" data-roadmap-grip="end" data-roadmap-week-rem="@RoadmapGeometry.N(Geometry.WeekWidthAt(Bar.End.AddDays(-6)))" data-roadmap-edge-rem="@RoadmapGeometry.N(Geometry.XFor(Bar.End.AddDays(1)))" aria-hidden="true"></span>
}

@* A pointer affordance like the edges, and for the same reason no tab stop: the
Expand Down
9 changes: 9 additions & 0 deletions src/Core/Backlog.UI.Components/Roadmap/RoadmapWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ private RoadmapWindow(IReadOnlyList<RoadmapColumn> columns)
/// width everywhere along the track.</summary>
public bool IsGraduated { get; }

/// <summary>
/// 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
/// <see cref="Graduated"/> 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.
/// </summary>
public IReadOnlyList<DateOnly> ColumnBoundaries => [.. Columns.Select(column => column.Start), End.AddDays(1)];

/// <summary>How many days the window spans, counting both ends. Never zero,
/// so it is always safe to divide by.</summary>
public int TotalDays => End.DayNumber - Start.DayNumber + 1;
Expand Down
31 changes: 30 additions & 1 deletion src/Core/Backlog.UI.Components/wwwroot/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Nothing here is a category the library invented — TARGETS, OPERATIONS and PROD
</Story>

<Story Name="Moving a bar, and moving it without a mouse"
Description="Drag a bar sideways to move it in time, up or down to change row, or by an edge to move one date. Every gesture snaps to the week, and the bar mid-drag is already at the position the drop will commit — the preview and the result are the same value.
Description="Drag a bar sideways to move it in time, up or down to change row, or by an edge to move one date. A move snaps to the week, and so does an edge on an axis ruled in quarters; on a graduated axis an edge snaps to the columns it is dragged across — a day near today, a month further out. The bar mid-drag is already at the position the drop will commit — the preview and the result are the same value.

The keyboard does the same: Tab to a bar, Space to pick it up, arrows to move it a week or a row, Shift or Alt with an arrow to pull an end. Space drops, Escape puts it back, and each step is announced through a live region."
Code="@EditCode">
Expand Down
97 changes: 97 additions & 0 deletions tests/Backlog.UI.Components.UnitTests/RoadmapGraduatedAxisTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoadmapTimeline>(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<RoadmapTimeline> RenderPlan(BunitContext context, params RoadmapBar[] extra) =>
context.Render<RoadmapTimeline>(parameters => parameters
.Add(timeline => timeline.Groups,
Expand Down
Loading