File: Utils\FormatHelpers.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.RegularExpressions;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
 
namespace Aspire.Dashboard.Utils;
 
public enum MillisecondsDisplay
{
    None,
    Truncated,
    Full
}
 
internal static partial class FormatHelpers
{
    // There are an unbound number of CultureInfo instances so we don't want to use it as the key.
    // Someone could have also customized their culture so we don't want to use the name as the key.
    // This struct contains required information from the culture that is used in cached format strings.
    private readonly record struct CultureDetailsKey(string LongTimePattern, string ShortDatePattern, string NumberDecimalSeparator);
    private sealed record MillisecondFormatStrings(MillisecondFormatString LongTimePattern, MillisecondFormatString ShortDateLongTimePattern);
    private sealed record MillisecondFormatString(string TruncatedMilliseconds, string FullMilliseconds);
    private static readonly ConcurrentDictionary<CultureDetailsKey, MillisecondFormatStrings> s_formatStrings = new();
 
    // Colon and dot are the only time separators used by registered cultures. Regex checks for both.
    [GeneratedRegex(@"(:ss|\.ss|:s|\.s)")]
    private static partial Regex MatchSecondsInTimeFormatPattern();
 
    private static MillisecondFormatStrings GetMillisecondFormatStrings(CultureInfo cultureInfo)
    {
        var key = new CultureDetailsKey(cultureInfo.DateTimeFormat.LongTimePattern, cultureInfo.DateTimeFormat.ShortDatePattern, cultureInfo.NumberFormat.NumberDecimalSeparator);
 
        return s_formatStrings.GetOrAdd(key, static k =>
        {
            var (truncated, full) = GetLongTimePatternWithMillisecondsCore(k);
            return new MillisecondFormatStrings(
                new MillisecondFormatString(truncated, full),
                new MillisecondFormatString(
                    k.ShortDatePattern + " " + truncated,
                    k.ShortDatePattern + " " + full));
        });
 
        static (string Truncated, string Full) GetLongTimePatternWithMillisecondsCore(CultureDetailsKey key)
        {
            // From https://learn.microsoft.com/dotnet/standard/base-types/how-to-display-milliseconds-in-date-and-time-values
 
            // Create a format similar to .fff but based on the current culture.
            // Intentionally use fff here instead of FFF so output has a consistent length.
            var truncatedMillisecondFormat = "fff";
 
            // Append millisecond pattern to current culture's long time pattern.
            return (
                FormatPattern(key, truncatedMillisecondFormat),
                FormatPattern(key, "FFFFFFF"));
        }
 
        static string FormatPattern(CultureDetailsKey key, string millisecondFormat)
        {
            // Gets the long time pattern, which is something like "h:mm:ss tt" (en-US), "H:mm:ss" (ja-JP), "HH:mm:ss" (fr-FR).
            var longTimePattern = key.LongTimePattern;
 
            return MatchSecondsInTimeFormatPattern().Replace(longTimePattern, $"$1'{key.NumberDecimalSeparator}'{millisecondFormat}");
        }
    }
 
    private static MillisecondFormatString GetLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).LongTimePattern;
 
    private static MillisecondFormatString GetShortDateLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).ShortDateLongTimePattern;
 
    public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
    {
        cultureInfo ??= CultureInfo.CurrentCulture;
        var local = timeProvider.ToLocal(value);
 
        // Long time
        return millisecondsDisplay switch
        {
            MillisecondsDisplay.None => local.ToString("T", cultureInfo),
            MillisecondsDisplay.Truncated => local.ToString(GetLongTimePatternWithMilliseconds(cultureInfo).TruncatedMilliseconds, cultureInfo),
            MillisecondsDisplay.Full => local.ToString(GetLongTimePatternWithMilliseconds(cultureInfo).FullMilliseconds, cultureInfo),
            _ => throw new NotImplementedException()
        };
    }
 
    public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
    {
        cultureInfo ??= CultureInfo.CurrentCulture;
        var local = timeProvider.ToLocal(value);
 
        // Short date, long time
        return millisecondsDisplay switch
        {
            MillisecondsDisplay.None => local.ToString("G", cultureInfo),
            MillisecondsDisplay.Truncated => local.ToString(GetShortDateLongTimePatternWithMilliseconds(cultureInfo).TruncatedMilliseconds, cultureInfo),
            MillisecondsDisplay.Full => local.ToString(GetShortDateLongTimePatternWithMilliseconds(cultureInfo).FullMilliseconds, cultureInfo),
            _ => throw new NotImplementedException()
        };
    }
 
    public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null)
    {
        var local = timeProvider.ToLocal(value);
 
        // If the date is today then only return time, otherwise return entire date time text.
        if (local.Date == DateTime.Now.Date)
        {
            // e.g. "08:57:44" (based on user's culture and preferences)
            // Don't include milliseconds as resource server returned time stamp is second precision.
            return FormatTime(timeProvider, local, millisecondsDisplay, cultureInfo);
        }
        else
        {
            // e.g. "9/02/2024 08:57:44" (based on user's culture and preferences)
            return FormatDateTime(timeProvider, local, millisecondsDisplay, cultureInfo);
        }
    }
 
    public static string FormatNumberWithOptionalDecimalPlaces(double value, int maxDecimalPlaces, CultureInfo? provider = null)
    {
        var formatString = maxDecimalPlaces switch
        {
            1 => "##,0.#",
            2 => "##,0.##",
            3 => "##,0.###",
            4 => "##,0.####",
            5 => "##,0.#####",
            6 => "##,0.######",
            _ => throw new ArgumentException("Unexpected value.", nameof(maxDecimalPlaces))
        };
        return value.ToString(formatString, provider ?? CultureInfo.CurrentCulture);
    }
}