diff --git a/readme.md b/readme.md index 79ab0e674..c594e2e61 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,7 @@ Humanizer meets all your .NET needs for manipulating and displaying strings, enu - [Number to words](#number-to-words) - [Number to ordinal words](#number-to-ordinal-words) - [DateTime to ordinal words](#date-time-to-ordinal-words) + - [TimeOnly to Clock Notation](#time-only-to-clock-notation) - [Roman numerals](#roman-numerals) - [Metric numerals](#metric-numerals) - [ByteSize](#bytesize) @@ -864,6 +865,20 @@ The possible values are `GrammaticalCase.Nominative`, `GrammaticalCase.Genitive` Obviously this only applies to some cultures. For others passing case in doesn't make any difference in the result. +### TimeOnly to Clock Notation +Extends TimeOnly to allow humanizing it to a clock notation +```C# +// for English US locale +new TimeOnly(3, 0).ToClockNotation() => "three o'clock" +new TimeOnly(12, 0).ToClockNotation() => "noon" +new TimeOnly(14, 30).ToClockNotation() => "half past two" + +// for Brazilian Portuguese locale +new TimeOnly(3, 0).ToClockNotation() => "três em ponto" +new TimeOnly(12, 0).ToClockNotation() => "meio-dia" +new TimeOnly(14, 30).ToClockNotation() => "duas e meia" +``` + ### Roman numerals Humanizer can change numbers to Roman numerals using the `ToRoman` extension. The numbers 1 to 10 can be expressed in Roman numerals as follows: diff --git a/src/Humanizer.Tests.Shared/Humanizer.Tests.Shared.projitems b/src/Humanizer.Tests.Shared/Humanizer.Tests.Shared.projitems index d2a2bcb77..f67af31f6 100644 --- a/src/Humanizer.Tests.Shared/Humanizer.Tests.Shared.projitems +++ b/src/Humanizer.Tests.Shared/Humanizer.Tests.Shared.projitems @@ -25,6 +25,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/src/Humanizer.Tests.Shared/Localisation/en/TimeToClockNotationTests.cs b/src/Humanizer.Tests.Shared/Localisation/en/TimeToClockNotationTests.cs new file mode 100644 index 000000000..9b19d0669 --- /dev/null +++ b/src/Humanizer.Tests.Shared/Localisation/en/TimeToClockNotationTests.cs @@ -0,0 +1,63 @@ +#if NET6_0_OR_GREATER + +using System; +using Xunit; +using Humanizer.Localisation.TimeToClockNotation; +using Humanizer; + +namespace Humanizer.Tests.Localisation.en +{ + [UseCulture("en")] + public class TimeToClockNotationTests + { + [Theory] + [InlineData(00, 00, "midnight")] + [InlineData(04, 00, "four o'clock")] + [InlineData(05, 01, "five one")] + [InlineData(06, 05, "five past six")] + [InlineData(07, 10, "ten past seven")] + [InlineData(08, 15, "a quarter past eight")] + [InlineData(09, 20, "twenty past nine")] + [InlineData(10, 25, "twenty-five past ten")] + [InlineData(11, 30, "half past eleven")] + [InlineData(12, 00, "noon")] + [InlineData(15, 35, "three thirty-five")] + [InlineData(16, 40, "twenty to five")] + [InlineData(17, 45, "a quarter to six")] + [InlineData(18, 50, "ten to seven")] + [InlineData(19, 55, "five to eight")] + [InlineData(20, 59, "eight fifty-nine")] + public void ConvertToClockNotationTimeOnlyStringEnUs(int hours, int minutes, string expectedResult) + { + var actualResult = new TimeOnly(hours, minutes).ToClockNotation(); + Assert.Equal(expectedResult, actualResult); + } + + [Theory] + [InlineData(00, 00, "midnight")] + [InlineData(04, 00, "four o'clock")] + [InlineData(05, 01, "five o'clock")] + [InlineData(06, 05, "five past six")] + [InlineData(07, 10, "ten past seven")] + [InlineData(08, 15, "a quarter past eight")] + [InlineData(09, 20, "twenty past nine")] + [InlineData(10, 25, "twenty-five past ten")] + [InlineData(11, 30, "half past eleven")] + [InlineData(12, 00, "noon")] + [InlineData(13, 23, "twenty-five past one")] + [InlineData(14, 32, "half past two")] + [InlineData(15, 35, "three thirty-five")] + [InlineData(16, 40, "twenty to five")] + [InlineData(17, 45, "a quarter to six")] + [InlineData(18, 50, "ten to seven")] + [InlineData(19, 55, "five to eight")] + [InlineData(20, 59, "nine o'clock")] + public void ConvertToRoundedClockNotationTimeOnlyStringEnUs(int hours, int minutes, string expectedResult) + { + var actualResult = new TimeOnly(hours, minutes).ToClockNotation(ClockNotationRounding.NearestFiveMinutes); + Assert.Equal(expectedResult, actualResult); + } + } +} + +#endif diff --git a/src/Humanizer.Tests.Shared/Localisation/pt-BR/TimeToClockNotationTests.cs b/src/Humanizer.Tests.Shared/Localisation/pt-BR/TimeToClockNotationTests.cs new file mode 100644 index 000000000..d232338bc --- /dev/null +++ b/src/Humanizer.Tests.Shared/Localisation/pt-BR/TimeToClockNotationTests.cs @@ -0,0 +1,63 @@ +#if NET6_0_OR_GREATER + +using System; +using Xunit; +using Humanizer.Localisation.TimeToClockNotation; +using Humanizer; + +namespace Humanizer.Tests.Localisation.ptBR +{ + [UseCulture("pt-BR")] + public class TimeToClockNotationTests + { + [Theory] + [InlineData(00, 00, "meia-noite")] + [InlineData(04, 00, "quatro em ponto")] + [InlineData(05, 01, "cinco e um")] + [InlineData(06, 05, "seis e cinco")] + [InlineData(07, 10, "sete e dez")] + [InlineData(08, 15, "oito e quinze")] + [InlineData(09, 20, "nove e vinte")] + [InlineData(10, 25, "dez e vinte e cinco")] + [InlineData(11, 30, "onze e meia")] + [InlineData(12, 00, "meio-dia")] + [InlineData(15, 35, "três e trinta e cinco")] + [InlineData(16, 40, "vinte para as cinco")] + [InlineData(17, 45, "quinze para as seis")] + [InlineData(18, 50, "dez para as sete")] + [InlineData(19, 55, "cinco para as oito")] + [InlineData(20, 59, "oito e cinquenta e nove")] + public void ConvertToClockNotationTimeOnlyStringPtBr(int hours, int minutes, string expectedResult) + { + var actualResult = new TimeOnly(hours, minutes).ToClockNotation(); + Assert.Equal(expectedResult, actualResult); + } + + [Theory] + [InlineData(00, 00, "meia-noite")] + [InlineData(04, 00, "quatro em ponto")] + [InlineData(05, 01, "cinco em ponto")] + [InlineData(06, 05, "seis e cinco")] + [InlineData(07, 10, "sete e dez")] + [InlineData(08, 15, "oito e quinze")] + [InlineData(09, 20, "nove e vinte")] + [InlineData(10, 25, "dez e vinte e cinco")] + [InlineData(11, 30, "onze e meia")] + [InlineData(12, 00, "meio-dia")] + [InlineData(13, 23, "uma e vinte e cinco")] + [InlineData(14, 32, "duas e meia")] + [InlineData(15, 35, "três e trinta e cinco")] + [InlineData(16, 40, "vinte para as cinco")] + [InlineData(17, 45, "quinze para as seis")] + [InlineData(18, 50, "dez para as sete")] + [InlineData(19, 55, "cinco para as oito")] + [InlineData(20, 59, "nove em ponto")] + public void ConvertToRoundedClockNotationTimeOnlyStringPtBr(int hours, int minutes, string expectedResult) + { + var actualResult = new TimeOnly(hours, minutes).ToClockNotation(ClockNotationRounding.NearestFiveMinutes); + Assert.Equal(expectedResult, actualResult); + } + } +} + +#endif diff --git a/src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.approve_public_api.approved.txt b/src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.approve_public_api.approved.txt index cfc0bca08..5d2374c2a 100644 --- a/src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.approve_public_api.approved.txt +++ b/src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.approve_public_api.approved.txt @@ -60,6 +60,11 @@ namespace Humanizer { public static string ApplyCase(this string input, Humanizer.LetterCasing casing) { } } + public enum ClockNotationRounding + { + None = 0, + NearestFiveMinutes = 1, + } public class static CollectionHumanizeExtensions { public static string Humanize(this System.Collections.Generic.IEnumerable collection) { } @@ -1581,6 +1586,10 @@ namespace Humanizer public static string Humanize(this string input) { } public static string Humanize(this string input, Humanizer.LetterCasing casing) { } } + public class static TimeOnlyToClockNotationExtensions + { + public static string ToClockNotation(this System.TimeOnly input, Humanizer.ClockNotationRounding roundToNearestFive = 0) { } + } public class static TimeSpanHumanizeExtensions { public static string Humanize(this System.TimeSpan timeSpan, int precision = 1, System.Globalization.CultureInfo culture = null, Humanizer.Localisation.TimeUnit maxUnit = 5, Humanizer.Localisation.TimeUnit minUnit = 0, string collectionSeparator = ", ", bool toWords = False) { } @@ -1736,6 +1745,7 @@ namespace Humanizer.Configuration public static Humanizer.Configuration.LocaliserRegistry NumberToWordsConverters { get; } public static Humanizer.Configuration.LocaliserRegistry Ordinalizers { get; } public static Humanizer.DateTimeHumanizeStrategy.ITimeOnlyHumanizeStrategy TimeOnlyHumanizeStrategy { get; set; } + public static Humanizer.Configuration.LocaliserRegistry TimeOnlyToClockNotationConverters { get; } } public class LocaliserRegistry where TLocaliser : class @@ -1945,4 +1955,11 @@ namespace Humanizer.Localisation.Ordinalizers string Convert(int number, string numberString); string Convert(int number, string numberString, Humanizer.GrammaticalGender gender); } +} +namespace Humanizer.Localisation.TimeToClockNotation +{ + public interface ITimeOnlyToClockNotationConverter + { + string Convert(System.TimeOnly time, Humanizer.ClockNotationRounding roundToNearestFive); + } } \ No newline at end of file diff --git a/src/Humanizer/ClockNotationRounding.cs b/src/Humanizer/ClockNotationRounding.cs new file mode 100644 index 000000000..7d9fa8f71 --- /dev/null +++ b/src/Humanizer/ClockNotationRounding.cs @@ -0,0 +1,8 @@ +namespace Humanizer +{ + public enum ClockNotationRounding + { + None, + NearestFiveMinutes + } +} diff --git a/src/Humanizer/Configuration/Configurator.cs b/src/Humanizer/Configuration/Configurator.cs index f9e5dcbae..157d76865 100644 --- a/src/Humanizer/Configuration/Configurator.cs +++ b/src/Humanizer/Configuration/Configurator.cs @@ -7,6 +7,9 @@ using Humanizer.Localisation.Formatters; using Humanizer.Localisation.NumberToWords; using Humanizer.Localisation.Ordinalizers; +#if NET6_0_OR_GREATER +using Humanizer.Localisation.TimeToClockNotation; +#endif namespace Humanizer.Configuration { @@ -70,6 +73,15 @@ public static LocaliserRegistry DateOnlyToOrdin { get { return _dateOnlyToOrdinalWordConverters; } } + + private static readonly LocaliserRegistry _timeOnlyToClockNotationConverters = new TimeOnlyToClockNotationConvertersRegistry(); + /// + /// A registry of time to clock notation converters used to localise ToClockNotation methods + /// + public static LocaliserRegistry TimeOnlyToClockNotationConverters + { + get { return _timeOnlyToClockNotationConverters; } + } #endif internal static ICollectionFormatter CollectionFormatter @@ -131,6 +143,14 @@ internal static IDateOnlyToOrdinalWordConverter DateOnlyToOrdinalWordsConverter return DateOnlyToOrdinalWordsConverters.ResolveForUiCulture(); } } + + internal static ITimeOnlyToClockNotationConverter TimeOnlyToClockNotationConverter + { + get + { + return TimeOnlyToClockNotationConverters.ResolveForUiCulture(); + } + } #endif private static IDateTimeHumanizeStrategy _dateTimeHumanizeStrategy = new DefaultDateTimeHumanizeStrategy(); diff --git a/src/Humanizer/Configuration/TimeOnlyToClockNotationConvertersRegistry.cs b/src/Humanizer/Configuration/TimeOnlyToClockNotationConvertersRegistry.cs new file mode 100644 index 000000000..07d435e29 --- /dev/null +++ b/src/Humanizer/Configuration/TimeOnlyToClockNotationConvertersRegistry.cs @@ -0,0 +1,16 @@ +#if NET6_0_OR_GREATER + +using Humanizer.Localisation.TimeToClockNotation; + +namespace Humanizer.Configuration +{ + internal class TimeOnlyToClockNotationConvertersRegistry : LocaliserRegistry + { + public TimeOnlyToClockNotationConvertersRegistry() : base(new DefaultTimeOnlyToClockNotationConverter()) + { + Register("pt-BR", new BrazilianPortugueseTimeOnlyToClockNotationConverter()); + } + } +} + +#endif diff --git a/src/Humanizer/Localisation/TimeToClockNotation/BrazilianPortugueseTimeOnlyToClockNotationConverter.cs b/src/Humanizer/Localisation/TimeToClockNotation/BrazilianPortugueseTimeOnlyToClockNotationConverter.cs new file mode 100644 index 000000000..d295d808b --- /dev/null +++ b/src/Humanizer/Localisation/TimeToClockNotation/BrazilianPortugueseTimeOnlyToClockNotationConverter.cs @@ -0,0 +1,41 @@ +#if NET6_0_OR_GREATER + +using System; + +using Humanizer; + +namespace Humanizer.Localisation.TimeToClockNotation +{ + internal class BrazilianPortugueseTimeOnlyToClockNotationConverter : ITimeOnlyToClockNotationConverter + { + public virtual string Convert(TimeOnly time, ClockNotationRounding roundToNearestFive) + { + switch (time) + { + case { Hour: 0, Minute: 0 }: + return "meia-noite"; + case { Hour: 12, Minute: 0 }: + return "meio-dia"; + } + + var normalizedHour = time.Hour % 12; + var normalizedMinutes = (int)(roundToNearestFive == ClockNotationRounding.NearestFiveMinutes + ? 5 * Math.Round(time.Minute / 5.0) + : time.Minute); + + return normalizedMinutes switch + { + 00 => $"{normalizedHour.ToWords(GrammaticalGender.Feminine)} em ponto", + 30 => $"{normalizedHour.ToWords(GrammaticalGender.Feminine)} e meia", + 40 => $"vinte para as {(normalizedHour + 1).ToWords(GrammaticalGender.Feminine)}", + 45 => $"quinze para as {(normalizedHour + 1).ToWords(GrammaticalGender.Feminine)}", + 50 => $"dez para as {(normalizedHour + 1).ToWords(GrammaticalGender.Feminine)}", + 55 => $"cinco para as {(normalizedHour + 1).ToWords(GrammaticalGender.Feminine)}", + 60 => $"{(normalizedHour + 1).ToWords(GrammaticalGender.Feminine)} em ponto", + _ => $"{normalizedHour.ToWords(GrammaticalGender.Feminine)} e {normalizedMinutes.ToWords()}" + }; + } + } +} + +#endif diff --git a/src/Humanizer/Localisation/TimeToClockNotation/DefaultTimeOnlyToClockNotationConverter.cs b/src/Humanizer/Localisation/TimeToClockNotation/DefaultTimeOnlyToClockNotationConverter.cs new file mode 100644 index 000000000..b01954dca --- /dev/null +++ b/src/Humanizer/Localisation/TimeToClockNotation/DefaultTimeOnlyToClockNotationConverter.cs @@ -0,0 +1,46 @@ +#if NET6_0_OR_GREATER + +using System; + +using Humanizer; + +namespace Humanizer.Localisation.TimeToClockNotation +{ + internal class DefaultTimeOnlyToClockNotationConverter : ITimeOnlyToClockNotationConverter + { + public virtual string Convert(TimeOnly time, ClockNotationRounding roundToNearestFive) + { + switch (time) + { + case { Hour: 0, Minute: 0 }: + return "midnight"; + case { Hour: 12, Minute: 0 }: + return "noon"; + } + + var normalizedHour = time.Hour % 12; + var normalizedMinutes = (int)(roundToNearestFive == ClockNotationRounding.NearestFiveMinutes + ? 5 * Math.Round(time.Minute / 5.0) + : time.Minute); + + return normalizedMinutes switch + { + 00 => $"{normalizedHour.ToWords()} o'clock", + 05 => $"five past {normalizedHour.ToWords()}", + 10 => $"ten past {normalizedHour.ToWords()}", + 15 => $"a quarter past {normalizedHour.ToWords()}", + 20 => $"twenty past {normalizedHour.ToWords()}", + 25 => $"twenty-five past {normalizedHour.ToWords()}", + 30 => $"half past {normalizedHour.ToWords()}", + 40 => $"twenty to {(normalizedHour + 1).ToWords()}", + 45 => $"a quarter to {(normalizedHour + 1).ToWords()}", + 50 => $"ten to {(normalizedHour + 1).ToWords()}", + 55 => $"five to {(normalizedHour + 1).ToWords()}", + 60 => $"{(normalizedHour + 1).ToWords()} o'clock", + _ => $"{normalizedHour.ToWords()} {normalizedMinutes.ToWords()}" + }; + } + } +} + +#endif diff --git a/src/Humanizer/Localisation/TimeToClockNotation/ITimeOnlyToClockNotationConverter.cs b/src/Humanizer/Localisation/TimeToClockNotation/ITimeOnlyToClockNotationConverter.cs new file mode 100644 index 000000000..1920ebb72 --- /dev/null +++ b/src/Humanizer/Localisation/TimeToClockNotation/ITimeOnlyToClockNotationConverter.cs @@ -0,0 +1,24 @@ +#if NET6_0_OR_GREATER + +using System; + +using Humanizer; + +namespace Humanizer.Localisation.TimeToClockNotation +{ + /// + /// The interface used to localise the ToClockNotation method. + /// + public interface ITimeOnlyToClockNotationConverter + { + /// + /// Converts the time to Clock Notation + /// + /// + /// + /// + string Convert(TimeOnly time, ClockNotationRounding roundToNearestFive); + } +} + +#endif diff --git a/src/Humanizer/TimeOnlyToClockNotationExtensions.cs b/src/Humanizer/TimeOnlyToClockNotationExtensions.cs new file mode 100644 index 000000000..aaa501b0a --- /dev/null +++ b/src/Humanizer/TimeOnlyToClockNotationExtensions.cs @@ -0,0 +1,29 @@ +#if NET6_0_OR_GREATER + +using System; + +using Humanizer; +using Humanizer.Configuration; +using Humanizer.Localisation.TimeToClockNotation; + +namespace Humanizer +{ + /// + /// Humanizes TimeOnly into human readable sentence + /// + public static class TimeOnlyToClockNotationExtensions + { + /// + /// Turns the provided time into clock notation + /// + /// The time to be made into clock notation + /// Whether to round the minutes to the nearest five or not + /// The time in clock notation + public static string ToClockNotation(this TimeOnly input, ClockNotationRounding roundToNearestFive = ClockNotationRounding.None) + { + return Configurator.TimeOnlyToClockNotationConverter.Convert(input, roundToNearestFive); + } + } +} + +#endif