Fix labels in fee rate graph
Improved label generation that avoids labels like "1h, 60m"
DELETE
WalletWasabi.Fluent/Converters/FeeTargetTimeConverter.cs
-using Avalonia.Data.Converters;
-using System.Globalization;
-using Avalonia;
-using WalletWasabi.Helpers;
-
-namespace WalletWasabi.Fluent.Converters;
-
-public class FeeTargetTimeConverter : IValueConverter
-{
- public static string Convert(int feeTarget, string minutesLabel, string hourLabel, string hoursLabel, string dayLabel, string daysLabel)
- {
- if (feeTarget == Constants.FastestConfirmationTarget)
- {
- return "fastest";
- }
-
- if (feeTarget is >= Constants.TwentyMinutesConfirmationTarget and <= 6) // minutes
- {
- return $"{feeTarget}0{minutesLabel}";
- }
-
- if (feeTarget is >= 7 and <= Constants.OneDayConfirmationTarget) // hours
- {
- var hours = feeTarget / 6; // 6 blocks per hour
- return $"{hours}{IfPlural(hours, hourLabel, hoursLabel)}";
- }
-
- if (feeTarget is >= (Constants.OneDayConfirmationTarget + 1) and < Constants.SevenDaysConfirmationTarget) // days
- {
- var days = feeTarget / Constants.OneDayConfirmationTarget;
- return $"{days}{IfPlural(days, dayLabel, daysLabel)}";
- }
-
- if (feeTarget == Constants.SevenDaysConfirmationTarget)
- {
- return "one week";
- }
-
- return "> one week";
- }
-
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
- {
- if (value is int feeTarget)
- {
- return Convert(feeTarget, " minutes", " hour", " hours", " day", " days");
- }
-
- return AvaloniaProperty.UnsetValue;
- }
-
- public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
- {
- throw new NotImplementedException();
- }
-
- private static string IfPlural(int val, string singular, string plural)
- {
- return val == 1 ? singular : plural;
- }
-}

ADD
WalletWasabi.Fluent/Extensions/TimeSpanMixin.cs
+namespace WalletWasabi.Fluent.Extensions;
+
+public static class TimeSpanMixin
+{
+ public static TimeSpan Reduce(this TimeSpan input)
+ {
+ if (input.Days > 0)
+ {
+ return ReduceToDays(input);
+ }
+
+ if (input.Hours > 0)
+ {
+ return ReduceToHours(input);
+ }
+
+ return ReduceToMinutes(input);
+ }
+
+ private static TimeSpan ReduceToDays(TimeSpan input)
+ {
+ if (input.Hours >= 12)
+ {
+ return TimeSpan.FromDays(input.Days + 1);
+ }
+
+ return TimeSpan.FromDays(input.Days);
+ }
+
+ private static TimeSpan ReduceToHours(TimeSpan input)
+ {
+ if (input.Hours >= 12)
+ {
+ return TimeSpan.FromDays(input.Days + 1);
+ }
+
+ if (input.Minutes >= 30)
+ {
+ return TimeSpan.FromHours(input.Hours + 1);
+ }
+
+ return TimeSpan.FromHours(input.Hours);
+ }
+
+ private static TimeSpan ReduceToMinutes(TimeSpan input)
+ {
+ return TimeSpan.FromMinutes(input.Minutes);
+ }
+}
\ No newline at end of file

ADD
WalletWasabi.Fluent/Helpers/ConfirmationTimeLabel.cs
+namespace WalletWasabi.Fluent.Helpers;
+
+public static class ConfirmationTimeLabel
+{
+ public static string AxisLabel(TimeSpan timeSpan)
+ {
+ if (timeSpan <= TransactionFeeHelper.CalculateConfirmationTime(WalletWasabi.Helpers.Constants.FastestConfirmationTarget))
+ {
+ return "fastest";
+ }
+
+ return TimeSpanFormatter.Format(timeSpan, new TimeSpanFormatter.Configuration("d", "h", "m"));
+ }
+
+ public static string SliderLabel(TimeSpan timeSpan)
+ {
+ if (timeSpan <= TransactionFeeHelper.CalculateConfirmationTime(WalletWasabi.Helpers.Constants.FastestConfirmationTarget))
+ {
+ return "fastest";
+ }
+
+ return "~" + TimeSpanFormatter.Format(timeSpan, new TimeSpanFormatter.Configuration(" days", " hours", " minutes"));
+ }
+}
\ No newline at end of file

ADD
WalletWasabi.Fluent/Helpers/TimeSpanFormatter.cs
+using System.Linq;
+using WalletWasabi.Fluent.Extensions;
+
+namespace WalletWasabi.Fluent.Helpers;
+
+public static class TimeSpanFormatter
+{
+ public static string Format(TimeSpan timeSpan, Configuration configuration)
+ {
+ var reduced = timeSpan.Reduce();
+
+ var parts = new[]
+ {
+ GetDays(reduced, configuration),
+ GetHours(reduced, configuration),
+ GetMinutes(reduced, configuration)
+ };
+
+ return parts.First(s => s is not null) ?? throw new InvalidOperationException($"Invalid timeSpan: {timeSpan}");
+ }
+
+ private static string? GetDays(TimeSpan timeSpan, Configuration configuration)
+ {
+ if (timeSpan.Days > 0)
+ {
+ return timeSpan.Days + configuration.DaysLabel;
+ }
+
+ return null;
+ }
+
+ private static string? GetHours(TimeSpan timeSpan, Configuration configuration)
+ {
+ if (timeSpan.Hours > 0)
+ {
+ return timeSpan.Hours + configuration.HoursLabel;
+ }
+
+ return null;
+ }
+
+ private static string? GetMinutes(TimeSpan timeSpan, Configuration configuration)
+ {
+ if (timeSpan.Minutes > 0)
+ {
+ return timeSpan.Minutes + configuration.MinutesLabel;
+ }
+
+ return default;
+ }
+
+ public class Configuration
+ {
+ public string DaysLabel { get; }
+ public string HoursLabel { get; }
+ public string MinutesLabel { get; }
+
+ public Configuration(string daysLabel, string hoursLabel, string minutesLabel)
+ {
+ DaysLabel = daysLabel;
+ HoursLabel = hoursLabel;
+ MinutesLabel = minutesLabel;
+ }
+ }
+}
\ No newline at end of file

MODIFY
WalletWasabi.Fluent/ViewModels/Wallets/Send/FeeChartViewModel.cs
WalletWasabi.Fluent/ViewModels/Wallets/Send/FeeChartViewModel.cs
@@ -57,7 +57,9 @@ public partial class FeeChartViewModel : ViewModelBase
private void UpdateFeeAndEstimate(double confirmationTarget)
{
CurrentSatoshiPerByte = GetSatoshiPerByte(confirmationTarget);
- CurrentConfirmationTargetString = FeeTargetTimeConverter.Convert((int)Math.Ceiling(confirmationTarget), " minutes", " hour", " hours", " day", " days");
+ var targetBlock = (int)Math.Ceiling(confirmationTarget);
+ var estimatedTime = TransactionFeeHelper.CalculateConfirmationTime(targetBlock);
+ CurrentConfirmationTargetString = ConfirmationTimeLabel.SliderLabel(estimatedTime);
}
private void SetSliderValue(double confirmationTarget)
@@ -279,7 +281,8 @@ public partial class FeeChartViewModel : ViewModelBase
for (int i = 0; i < blockTargetCount; i += interval)
{
- var label = FeeTargetTimeConverter.Convert((int)Math.Ceiling(confirmationTargets[i]), "m", "h", "h", "d", "d");
+ var targetBlock = (int)Math.Ceiling(confirmationTargets[i]);
+ var label = ConfirmationTimeLabel.AxisLabel(TransactionFeeHelper.CalculateConfirmationTime(targetBlock));
if (i + interval <= blockTargetCount)
{
@@ -289,7 +292,8 @@ public partial class FeeChartViewModel : ViewModelBase
if (interval != 1)
{
- yield return FeeTargetTimeConverter.Convert((int)Math.Ceiling(confirmationTargets.Last()), "m", "h", "h", "d", "d");
+ var targetBlock = (int)Math.Ceiling(confirmationTargets.Last());
+ yield return ConfirmationTimeLabel.AxisLabel(TransactionFeeHelper.CalculateConfirmationTime(targetBlock));
}
}

MODIFY
WalletWasabi.Fluent/Views/Wallets/Send/SendFeeView.axaml
WalletWasabi.Fluent/Views/Wallets/Send/SendFeeView.axaml
<ToolTip>
<StackPanel Spacing="10" x:CompileBindings="False">
<TextBlock Text="{Binding $parent[Slider].DataContext.CurrentSatoshiPerByte, Mode=OneWay, StringFormat='Fee Rate: \{0:0\} sat/vByte'}" />
- <TextBlock Text="{Binding $parent[Slider].DataContext.CurrentConfirmationTargetString, Mode=OneWay, StringFormat='Estimated Time: ~\{0\}'}" />
+ <TextBlock Text="{Binding $parent[Slider].DataContext.CurrentConfirmationTargetString, Mode=OneWay, StringFormat='Estimated Time: \{0\}'}" />
</StackPanel>
</ToolTip>
</Template>

ADD
WalletWasabi.Tests/UnitTests/Extensions/TimeSpanMixinTests.cs
+using System.Globalization;
+using WalletWasabi.Fluent.Extensions;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Extensions;
+
+public class TimeSpanMixinTests
+{
+ [Theory]
+ [InlineData("1:0:0", "1:0:0")] // input and expected are formatted as {days}:{hours}:{minutes}
+ [InlineData("1:0:33", "1:0:0")]
+ [InlineData("1:1:0", "1:0:0")]
+ [InlineData("0:12:0", "1:0:0")]
+ [InlineData("1:12:0", "2:0:0")]
+ [InlineData("1:13:0", "2:0:0")]
+ [InlineData("0:15:59", "1:0:0")]
+ [InlineData("0:9:59", "0:10:0")]
+ [InlineData("0:0:35", "0:0:35")]
+ [InlineData("0:0:20", "0:0:20")]
+ [InlineData("0:0:50", "0:0:50")]
+ [InlineData("0:1:30", "0:2:0")]
+ [InlineData("0:1:1", "0:1:0")]
+ [InlineData("0:0:1", "0:0:1")]
+ [InlineData("0:0:0", "0:0:0")]
+ public void Reduce(string inputStr, string expectedStr)
+ {
+ var input = ParseExact(inputStr);
+
+ var actual = input.Reduce();
+ var expected = ParseExact(expectedStr);
+ Assert.Equal(expected, actual);
+ }
+
+ private static TimeSpan ParseExact(string inputStr)
+ {
+ return TimeSpan.ParseExact(inputStr, @"d\:h\:m", CultureInfo.InvariantCulture);
+ }
+}
\ No newline at end of file

Fix labels in fee rate graph

Improved label generation that avoids labels like "1h, 60m"