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
42 changes: 36 additions & 6 deletions src/App/Backlog.Desktop.UI/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -5127,8 +5127,9 @@ h1, h2, h3 {

/* A band's pace, under its name in the chart's sidebar, in the two rows the band
reserves for it (RoadmapPlanView.PaceRows): on the first a slider and the figure
it is at, on the second the "Measured" reset, the info mark with what the lane
managed, and - only when something needs saying - one short note. Sized to the
it is at, on the second the "Measured" reset and - only when something needs
saying - one short note. The info mark with what the lane managed sits at the end
of the band's name, in the band's top-right corner (.roadmap-pace__hint). Sized to the
sidebar's own width, so every measure here is the smallest the library offers.
The reader's own pace - "Mine", the field and its unit - sits once in the
roadmap's heading (.roadmap-pace--mine). */
Expand Down Expand Up @@ -5174,8 +5175,13 @@ h1, h2, h3 {
min-width: 2rem;
}

/* A fixed width, wide enough for "999 pt/wk", so the slider beside it keeps its
length while the figure changes under a drag. A figure that grew or shrank the
slider moved the line under the pointer, and the thumb stuttered. */
.roadmap-pace__value {
flex: 0 0 auto;
width: 9ch;
text-align: right;
font-variant-numeric: tabular-nums;
}

Expand All @@ -5191,13 +5197,37 @@ h1, h2, h3 {
display: block;
}

/* The band's content is clipped to the rows it asked for, which would cut the bubble
off at the band's edge. While the bubble is showing - hover, focus or a press -
the clip is lifted so it can lie over the lanes below, where there is room. */
.roadmap-timeline__group-content:has(.roadmap-pace__hint:is(:hover, :focus-within, .info-hint--open)) {
/* The info mark, at the end of the band's name. The mark is lifted out of the
content box, which starts under the name, onto the name's line - centred on it,
since the name is centred on the band's first row and the box starts a fixed
0.75rem under that centre (RoadmapTimeline.ContentGapUnderName). The hint is made
static, so the content box is the bubble's containing block (the arrangement
components.css offers for a wider anchor): the bubble opens under the band's pace,
as wide as the sidebar allows, rather than off the mark at the sidebar's edge. */
.roadmap-pace .roadmap-pace__hint {
position: static;
}

.roadmap-pace__hint .info-hint__trigger {
position: absolute;
top: -0.75rem;
right: var(--spacing-sm);
transform: translateY(-50%);
}

/* The band's content is clipped to the rows it asked for. A band carrying a pace is
not: the mark sits above the box, on the name's line, and the bubble lies over the
lanes below. The pace's own lines fit the rows, so nothing else leaves the box. */
.roadmap-timeline__group-content:has(> .roadmap-pace) {
overflow: visible;
}

/* Room at the end of the band's name for the mark, so a long name is cut short
before it rather than running under it. */
.roadmap-timeline__group--slotted:has(.roadmap-pace__hint) .roadmap-timeline__group-name {
margin-right: 1.25rem;
}

.roadmap-pace__note {
overflow: hidden;
text-overflow: ellipsis;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ public event Action? Changed
public string? SetManual(string? typed, string? repository = null) => _settings.Set(typed, repository);

public string? Choose(PaceSource source, string? repository = null) => _settings.Choose(source, repository);

public string? SetOwn(string? typed, string? repository = null) => _settings.SetOwn(typed, repository);
}
Original file line number Diff line number Diff line change
Expand Up @@ -192,22 +192,7 @@ public bool KeepsOwnPace(string? repository) =>
/// </summary>
public string? Set(decimal storyPointsPerWeek, string? repository = null)
{
if (storyPointsPerWeek <= 0)
{
return "Give a pace above zero — a week that gets through no points has no length to draw.";
}

// Rounded to what is actually storable before the value is published, so the
// figure the roadmap divides by is the same one the file will hold. Rounding
// at the write instead would let the store keep a pace its own file cannot
// express, and lose it at the next start.
var storable = Normalize(storyPointsPerWeek);

if (storable < Smallest)
{
return FormattableString.Invariant(
$"Give a pace of at least {Smallest} - anything finer than that rounds away to nothing.");
}
if (Refusal(storyPointsPerWeek, out var storable) is { } refused) return refused;

// Unchanged is nothing to write — and, for a repository still reading the
// global pace, no reason to give it one of its own.
Expand All @@ -217,6 +202,57 @@ public bool KeepsOwnPace(string? repository) =>
return Save(With(_pace, repository, current with { StoryPointsPerWeek = storable }));
}

/// <summary>
/// Sets the pace and chooses <see cref="PaceSource.Set"/> — for
/// <paramref name="repository"/>, or globally for <c>null</c> — as one change:
/// what a band's slider does when it is let go. One write and one
/// <see cref="Changed"/>, where <see cref="Set(decimal, string?)"/> then
/// <see cref="Choose"/> would be two of each, and every pace on screen would read
/// the backlog again for both. A refused figure chooses nothing.
/// </summary>
public string? SetOwn(decimal storyPointsPerWeek, string? repository = null)
{
if (Refusal(storyPointsPerWeek, out var storable) is { } refused) return refused;

var own = new Setting(storable, PaceSource.Set);
if (Effective(_pace, repository) == own) return null;

return Save(With(_pace, repository, own));
}

/// <summary><see cref="SetOwn(decimal, string?)"/> over what a text field hands
/// back, through the one parsing rule <see cref="Set(string?, string?)"/> uses.</summary>
public string? SetOwn(string? typed, string? repository = null) =>
TryParse(typed, out var storyPointsPerWeek)
? SetOwn(storyPointsPerWeek, repository)
: NotANumber;

/// <summary>Why <paramref name="storyPointsPerWeek"/> cannot be kept, or
/// <c>null</c> with <paramref name="storable"/> set to the figure the file will
/// hold.
/// <para>
/// Rounded to what is actually storable before the value is published, so the
/// figure the roadmap divides by is the same one the file will hold. Rounding
/// at the write instead would let the store keep a pace its own file cannot
/// express, and lose it at the next start.
/// </para></summary>
private static string? Refusal(decimal storyPointsPerWeek, out decimal storable)
{
storable = 0m;

if (storyPointsPerWeek <= 0)
{
return "Give a pace above zero — a week that gets through no points has no length to draw.";
}

storable = Normalize(storyPointsPerWeek);

return storable < Smallest
? FormattableString.Invariant(
$"Give a pace of at least {Smallest} - anything finer than that rounds away to nothing.")
: null;
}

/// <summary>Chooses the pace the roadmap places by — for
/// <paramref name="repository"/>, or globally for <c>null</c>. Returns
/// <c>null</c> when it took and was saved, a refusal for a value that is not a
Expand All @@ -236,19 +272,15 @@ public bool KeepsOwnPace(string? repository) =>
/// does not carry a second copy of the parsing rule. Invariant on purpose: the
/// <c>number</c> input reports its value with a dot whatever the machine's
/// locale is.</summary>
public string? Set(string? typed, string? repository = null)
{
if (!decimal.TryParse(
typed,
PaceStyles,
CultureInfo.InvariantCulture,
out var storyPointsPerWeek))
{
return "Give a pace as a number, like 5 or 7.5.";
}
public string? Set(string? typed, string? repository = null) =>
TryParse(typed, out var storyPointsPerWeek)
? Set(storyPointsPerWeek, repository)
: NotANumber;

return Set(storyPointsPerWeek, repository);
}
private const string NotANumber = "Give a pace as a number, like 5 or 7.5.";

private static bool TryParse(string? typed, out decimal storyPointsPerWeek) =>
decimal.TryParse(typed, PaceStyles, CultureInfo.InvariantCulture, out storyPointsPerWeek);

private string? Save(Pace pace)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ public interface IPlanningPace
/// plans are placed at.</summary>
Task<PlanningPacesDto> ReadAsync(string? repository = null, CancellationToken cancellationToken = default);

/// <inheritdoc cref="IPlanningVelocity.ReadPacesInUseAsync"/>
Task<PacesInUseDto> ReadPacesInUseAsync(CancellationToken cancellationToken = default);

/// <summary>Sets the typed pace — the one fallback every scope shares — from what
/// a text field hands back. Returns <c>null</c> when it took and was saved, and a
/// message to show beside the field otherwise.</summary>
Expand Down Expand Up @@ -102,6 +105,12 @@ public interface IPlanningVelocitySettings

/// <inheritdoc cref="IPlanningPace.Choose"/>
string? Choose(PaceSource source, string? repository = null);

/// <summary>Sets the scope's typed pace from what a text field hands back and
/// chooses <see cref="PaceSource.Set"/>, as one change: one write and one
/// <see cref="Changed"/>, so a slider let go of is heard once. Returns what
/// <see cref="SetManual"/> would, and chooses nothing for a refused figure.</summary>
string? SetOwn(string? typed, string? repository = null);
}

/// <summary>
Expand Down
27 changes: 12 additions & 15 deletions src/Modules/Roadmap/Backlog.Modules.Roadmap.UI/PaceSliderRange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ namespace Backlog.Modules.Roadmap.UI;
/// them and it runs from half the typed pace to double it.
/// </para>
/// <para>
/// Both ends are moved outward onto a step so the thumb can rest on them, and the
/// line is always long enough to hold the pace in use, so the thumb is never drawn
/// somewhere the lane is not. The ends never go below one step — a pace of zero has
/// no length to draw — unless the pace in use is itself lower, which then is the end.
/// Stepped a whole point at a time, so a pace set with the slider is always a whole
/// number. Both ends are moved outward onto a whole point so the thumb can rest on
/// them, and the line is always long enough to reach the pace in use. It never starts
/// below one point — a pace of zero has no length to draw — so a lane in use at less
/// than a point a week is drawn at the start of the line.
/// </para>
/// </summary>
internal readonly record struct PaceSliderRange(decimal Min, decimal Max, decimal Step)
{
/// <summary>A quarter of a point a week: fine enough to place a lane by, coarse
/// enough to land on with a thumb.</summary>
public const decimal QuarterPoint = 0.25m;
/// <summary>A whole point a week: the one figure the slider sets.</summary>
public const decimal WholePoint = 1m;

public static PaceSliderRange Of(decimal? twoWeek, decimal typed, decimal current)
{
Expand All @@ -34,18 +34,15 @@ public static PaceSliderRange Of(decimal? twoWeek, decimal typed, decimal curren
high = Math.Max(measured, typed);
}

low = Math.Min(low, current);
high = Math.Max(high, current);

// The floor only applies to the ends the lane and the reader chose. A pace in
// use below it — the 8-week stretch of a quiet lane can measure an eighth of a
// point — still has to be on the line, so it is the end itself then.
var min = Math.Max(QuarterPoint, Math.Floor(low / QuarterPoint) * QuarterPoint);
if (current < min) min = current;
var max = Math.Ceiling(high / QuarterPoint) * QuarterPoint;
var min = Math.Max(WholePoint, Math.Floor(low));
var max = Math.Ceiling(high);

// One step at least, so there is a line to move along.
if (max <= min) max = min + QuarterPoint;
if (max <= min) max = min + WholePoint;

return new PaceSliderRange(min, max, QuarterPoint);
return new PaceSliderRange(min, max, WholePoint);
}
}
57 changes: 21 additions & 36 deletions src/Modules/Roadmap/Backlog.Modules.Roadmap.UI/RoadmapBand.razor
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,7 @@
with nothing planned there is nothing to place. *@
@if (_view.HasAnythingToDraw)
{
<RoadmapTypedPace Repositories="@([.. Configured().Select(repository => repository.Alias)])"
OnPaceChanged="RedrawAtNewPace" />
<RoadmapTypedPace Repositories="@([.. Configured().Select(repository => repository.Alias)])" />
}
</text>;

Expand All @@ -268,8 +267,7 @@
/// </para>
/// </summary>
private RenderFragment<RoadmapGroup> BandPace => group =>
@<RoadmapPaceControl Repository="@(group.Id == RoadmapPlanView.UnfiledGroupId ? null : group.Id)"
OnPaceChanged="RedrawAtNewPace" />;
@<RoadmapPaceControl Repository="@(group.Id == RoadmapPlanView.UnfiledGroupId ? null : group.Id)" />;

/// <summary>Where the "now" line is drawn. A parameter so a test can put
/// today somewhere it chooses rather than wherever the clock happens to
Expand Down Expand Up @@ -311,32 +309,17 @@
// left should be done at the pace its band is placed at; a plan nobody started
// that is sized by its effort, to where that effort reaches at the same pace
// (RoadmapPlanView.AsDrawn).
// Every band's pace from one read of the finished work, not one read per band.
var forecast = new RoadmapForecast(
Today ?? DateOnly.FromDateTime(DateTime.Today),
await PacesInUseAsync(configured));
await Paces.ReadPacesInUseAsync());

_view = RoadmapPlanView.From(_plan, configured, rollups, forecast);

_imported = await ImportedPlans.ListAsync();
_unplanned = Unplanned(_imported, _plan);
}

/// <summary>The pace each band is in use at — the one its pace control shows —
/// and the global one for work filed under none. Read on every load, which the
/// band already does when finished work or a pace changes.</summary>
private async Task<PacesInUseDto> PacesInUseAsync(IReadOnlyList<PlannedRepository> configured)
{
var global = (await Paces.ReadAsync()).InUse;
var byRepository = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);

foreach (var repository in configured)
{
byRepository[repository.Alias] = (await Paces.ReadAsync(repository.Alias)).InUse;
}

return new PacesInUseDto(global, byRepository);
}

/// <summary>
/// The imported plans no item on the plan carries the tag of, in the order the
/// backlog holds them.
Expand Down Expand Up @@ -762,18 +745,6 @@
}
}

/// <summary>
/// The reader changed their pace: every bar still sized by its effort is redrawn
/// at it, from what each item gathers now (ADR 0013, ruling 5 as amended).
/// <para>
/// A read, never a plan write. Those windows are derived where they are drawn
/// (<see cref="EffortWindow"/>), so the pace document is the only thing a pace
/// change writes — a plan saved here would carry a newer stamp to the other PCs and
/// overwrite an edit made there in the same interval (local ADR 0018). A pace that
/// arrives from another PC redraws through <see cref="OnPaceSettingsChanged"/>.
/// </para>
/// </summary>
private Task RedrawAtNewPace() => Load();

/// <summary>
/// "Update from tasks", taken: the item's window re-lengthened from what its tasks
Expand Down Expand Up @@ -947,9 +918,23 @@
/// holds.</summary>
private void OnWorkChanged() => ReloadForOutsideChange();

/// <summary>A typed pace or a choice changed — here, in another window, or pulled
/// from another PC by the sync — so every bar still sized by its effort is drawn
/// again at the pace now in use. Nothing is written: the window is read.</summary>
/// <summary>
/// A typed pace or a choice changed — in this band's own pace controls, in another
/// window, or pulled from another PC by the sync — so every bar still sized by its
/// effort is drawn again at the pace now in use, from what each item gathers now
/// (ADR 0013, ruling 5 as amended).
/// <para>
/// The one redraw a pace change gets: the controls' own OnPaceChanged is not
/// listened to as well, because it reports the same save and a second load would
/// read the backlog again for nothing — the stall after letting go of a slider.
/// </para>
/// <para>
/// A read, never a plan write. Those windows are derived where they are drawn
/// (<see cref="EffortWindow"/>), so the pace document is the only thing a pace
/// change writes — a plan saved here would carry a newer stamp to the other PCs and
/// overwrite an edit made there in the same interval (local ADR 0018).
/// </para>
/// </summary>
private void OnPaceSettingsChanged() => ReloadForOutsideChange();

/// <summary>A reload is already under way when the next change lands.</summary>
Expand Down
Loading
Loading