diff --git a/src/Clockify/Fill/EntryFillDialog.cs b/src/Clockify/Fill/EntryFillDialog.cs index 916a7ed..8dfc1e5 100644 --- a/src/Clockify/Fill/EntryFillDialog.cs +++ b/src/Clockify/Fill/EntryFillDialog.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Bot.Clockify.Client; using Bot.Clockify.Models; +using Bot.Common; using Bot.Common.ChannelData.Telegram; using Bot.Common.Recognizer; using Bot.Data; @@ -26,6 +27,7 @@ public class EntryFillDialog : ComponentDialog private readonly WorthAskingForTaskService _worthAskingForTask; private readonly UserState _userState; private readonly IClockifyMessageSource _messageSource; + private readonly IDateTimeProvider _dateTimeProvider; private readonly ILogger _logger; private const string TaskWaterfall = "TaskWaterfall"; @@ -41,7 +43,7 @@ public class EntryFillDialog : ComponentDialog public EntryFillDialog(ClockifyEntityRecognizer clockifyWorkableRecognizer, ITimeEntryStoreService timeEntryStoreService, WorthAskingForTaskService worthAskingForTask, UserState userState, IClockifyService clockifyService, ITokenRepository tokenRepository, - IClockifyMessageSource messageSource, ILogger logger) + IClockifyMessageSource messageSource, IDateTimeProvider dateTimeProvider, ILogger logger) { _clockifyWorkableRecognizer = clockifyWorkableRecognizer; _timeEntryStoreService = timeEntryStoreService; @@ -50,6 +52,7 @@ public EntryFillDialog(ClockifyEntityRecognizer clockifyWorkableRecognizer, _clockifyService = clockifyService; _tokenRepository = tokenRepository; _messageSource = messageSource; + _dateTimeProvider = dateTimeProvider; _logger = logger; AddDialog(new WaterfallDialog(TaskWaterfall, new List { @@ -77,8 +80,9 @@ private async Task PromptForTaskAsync(WaterfallStepContext ste var recognizedProject = await _clockifyWorkableRecognizer.RecognizeProject(luisResult.ProjectName(), clockifyToken); stepContext.Values["Project"] = recognizedProject; + stepContext.Values["TimeZone"] = userProfile.TimeZone; double minutes = luisResult.WorkedDurationInMinutes(); - var (start, end) = luisResult.WorkedPeriod(minutes); + var (start, end) = luisResult.WorkedPeriod(_dateTimeProvider, minutes, userProfile.TimeZone); stepContext.Values["Start"] = start; stepContext.Values["End"] = end; string fullEntity = recognizedProject.Name; @@ -247,12 +251,13 @@ private async Task ClockifyTaskValidatorAsync(PromptValidatorContext AddEntryAndExit(DialogContext stepContext, + private async Task AddEntryAndExit(WaterfallStepContext stepContext, CancellationToken cancellationToken, string clockifyToken, ProjectDo recognizedProject, DateTime start, DateTime end, string fullEntity, TaskDo? task) { + var timeZone = (TimeZoneInfo) stepContext.Values["TimeZone"]; double current = - await _timeEntryStoreService.AddTimeEntries(clockifyToken, recognizedProject, task, start, end); + await _timeEntryStoreService.AddTimeEntries(clockifyToken, recognizedProject, task, start, end, timeZone); string messageText = string.Format(_messageSource.AddEntryFeedback, (end-start).TotalMinutes, fullEntity, current); string platform = stepContext.Context.Activity.ChannelId; diff --git a/src/Clockify/Fill/ITimeEntryStoreService.cs b/src/Clockify/Fill/ITimeEntryStoreService.cs index 2e71a98..85581dd 100644 --- a/src/Clockify/Fill/ITimeEntryStoreService.cs +++ b/src/Clockify/Fill/ITimeEntryStoreService.cs @@ -6,6 +6,7 @@ namespace Bot.Clockify.Fill { public interface ITimeEntryStoreService { - public Task AddTimeEntries(string clockifyToken, ProjectDo project, TaskDo? task, DateTime start, DateTime end); + public Task AddTimeEntries(string clockifyToken, ProjectDo project, TaskDo? task, DateTime start, + DateTime end, TimeZoneInfo timeZone); } } \ No newline at end of file diff --git a/src/Clockify/Fill/TimeEntryStoreService.cs b/src/Clockify/Fill/TimeEntryStoreService.cs index bff7047..e657ba3 100644 --- a/src/Clockify/Fill/TimeEntryStoreService.cs +++ b/src/Clockify/Fill/TimeEntryStoreService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Bot.Clockify.Client; using Bot.Clockify.Models; +using Bot.Common; using Microsoft.Extensions.Configuration; namespace Bot.Clockify.Fill @@ -12,14 +13,18 @@ public class TimeEntryStoreService : ITimeEntryStoreService { private readonly IClockifyService _clockifyService; private readonly string _tagName; + private readonly IDateTimeProvider _dateTimeProvider; - public TimeEntryStoreService(IClockifyService clockifyService, IConfiguration configuration) + public TimeEntryStoreService(IClockifyService clockifyService, IConfiguration configuration, + IDateTimeProvider dateTimeProvider) { _clockifyService = clockifyService; + _dateTimeProvider = dateTimeProvider; _tagName = configuration["Tag"]; } - public async Task AddTimeEntries(string clockifyToken, ProjectDo project, TaskDo? task, DateTime start, DateTime end) + public async Task AddTimeEntries(string clockifyToken, ProjectDo project, TaskDo? task, DateTime start, + DateTime end, TimeZoneInfo timeZone) { string? tagId = await _clockifyService.GetTagAsync(clockifyToken, project.WorkspaceId, _tagName); string userId = (await _clockifyService.GetCurrentUserAsync(clockifyToken)).Id; @@ -31,13 +36,15 @@ public async Task AddTimeEntries(string clockifyToken, ProjectDo project taskId: task?.Id, billable: project.Billable, end: end, - tagIds: (tagId != null) ? new List {tagId} : null + tagIds: tagId != null ? new List { tagId } : null ); await _clockifyService.AddTimeEntryAsync(clockifyToken, workspaceId, timeEntry); + // TODO Extract total hours calculation into another method + var userToday = TimeZoneInfo.ConvertTime(_dateTimeProvider.DateTimeUtcNow(), timeZone).Date; var todayEntries = await _clockifyService.GetHydratedTimeEntriesAsync(clockifyToken, workspaceId, userId, - new DateTimeOffset(DateTime.Today), new DateTimeOffset(DateTime.Today.AddDays(1))); + new DateTimeOffset(userToday), new DateTimeOffset(userToday.AddDays(1))); return todayEntries .Where(entry => entry.TimeInterval.Start.HasValue && entry.TimeInterval.End.HasValue) diff --git a/src/Common/Recognizer/TimeSurveyBotLuisEx.cs b/src/Common/Recognizer/TimeSurveyBotLuisEx.cs index 17447ec..ae5ff04 100644 --- a/src/Common/Recognizer/TimeSurveyBotLuisEx.cs +++ b/src/Common/Recognizer/TimeSurveyBotLuisEx.cs @@ -69,18 +69,86 @@ ex is InvalidOperationException } } - public (DateTime start, DateTime end) WorkedPeriod(double minutes, string culture = Culture.English) + public (DateTime start, DateTime end) WorkedPeriod(IDateTimeProvider dateTimeProvider, double minutes, TimeZoneInfo timeZone, + string culture = Culture.English) { var workedPeriodInstances = Entities._instance.datetime; + var userNow = TimeZoneInfo.ConvertTime(dateTimeProvider.DateTimeUtcNow(), timeZone); if (workedPeriodInstances.Length > 1) { string? instance = workedPeriodInstances[1].Text; - var recognizedDateTime = DateTimeRecognizer.RecognizeDateTime(instance, culture).First(); + var recognizedDateTime = + DateTimeRecognizer.RecognizeDateTime(instance, culture, refTime: userNow).First(); var resolvedPeriod = ((List>)recognizedDateTime.Resolution["values"])[0]; - // TODO: use resolvedPeriod to pick a (start, end) period + return RecognizedWorkedPeriod(userNow, resolvedPeriod, minutes, timeZone); } - var thisMorning = DateTime.Today.AddHours(9); - return (thisMorning, thisMorning.AddMinutes(minutes)); + + var thisMorning = userNow.Date.AddHours(9); + var thisMorningUtc = TimeZoneInfo.ConvertTimeToUtc(thisMorning, timeZone); + return (thisMorningUtc, thisMorningUtc.AddMinutes(minutes)); + } + + private static (DateTime start, DateTime end) RecognizedWorkedPeriod(DateTime refTime, + IReadOnlyDictionary periodData, double minutes, TimeZoneInfo timeZone) + { + string dateTimeType = periodData["type"]; + if (dateTimeType.Equals("date")) + { + var date = DateTime.Parse(periodData["value"]); + var start = new DateTime(date.Year, date.Month, date.Day, 9, 0, 0); + var startUtc = TimeZoneInfo.ConvertTimeToUtc(start, timeZone); + return (startUtc, startUtc.AddMinutes(minutes)); + } + + if (dateTimeType.Equals("datetime")) + { + var datetime = ParseToUtc(periodData["value"], timeZone); + return (datetime, datetime.AddMinutes(minutes)); + } + + if (dateTimeType.Equals("timerange") && periodData.ContainsKey("Mod") && periodData["Mod"].Equals("before")) + { + var time = ParseToUtc(periodData["end"], timeZone); + var datetime = new DateTime(refTime.Year, refTime.Month, refTime.Day, time.Hour, time.Minute, + time.Second); + return (datetime.Subtract(TimeSpan.FromMinutes(minutes)), datetime); + } + + if (dateTimeType.Equals("timerange") && periodData.ContainsKey("Mod") && periodData["Mod"].Equals("since")) + { + var time = ParseToUtc(periodData["start"], timeZone); + var datetime = new DateTime(refTime.Year, refTime.Month, refTime.Day, time.Hour, time.Minute, + time.Second); + return (datetime, datetime.AddMinutes(minutes)); + } + + if (dateTimeType.Equals("timerange") && !periodData.ContainsKey("Mod")) + { + var timeStart = ParseToUtc(periodData["start"], timeZone); + var datetimeStart = new DateTime(refTime.Year, refTime.Month, refTime.Day, timeStart.Hour, + timeStart.Minute, timeStart.Second); + var timeEnd = ParseToUtc(periodData["end"], timeZone); + var datetimeEnd = new DateTime(refTime.Year, refTime.Month, refTime.Day, timeEnd.Hour, + timeEnd.Minute, timeEnd.Second); + + double minutesBetweenDates = datetimeEnd.Subtract(datetimeStart).TotalMinutes; + // Floating point comparison, we check that the difference is greater than one minute. + if (Math.Abs(minutesBetweenDates - minutes) > 1) + { + throw new InvalidWorkedPeriodException( + $"Worked period time span differs from the duration provided. Expected {minutes} but got {minutesBetweenDates}"); + } + + return (datetimeStart, datetimeEnd); + } + + throw new InvalidWorkedPeriodException($"Date time type {dateTimeType} is not allowed"); + } + + private static DateTime ParseToUtc(string localDateTimeString, TimeZoneInfo timeZone) + { + var localDt = DateTime.Parse(localDateTimeString); + return TimeZoneInfo.ConvertTimeToUtc(localDt, timeZone); } } } \ No newline at end of file diff --git a/tests/Bot.Tests/Common/Recognizer/TimeSurveyBotLuisExTest.cs b/tests/Bot.Tests/Common/Recognizer/TimeSurveyBotLuisExTest.cs index 5205e89..bcfefe2 100644 --- a/tests/Bot.Tests/Common/Recognizer/TimeSurveyBotLuisExTest.cs +++ b/tests/Bot.Tests/Common/Recognizer/TimeSurveyBotLuisExTest.cs @@ -1,7 +1,10 @@ using System; +using Bot.Common; using Bot.Common.Recognizer; using FluentAssertions; using Microsoft.Bot.Builder.AI.Luis; +using Moq; +using TimeZoneConverter; using Xunit; namespace Bot.Tests.Common.Recognizer @@ -9,7 +12,7 @@ namespace Bot.Tests.Common.Recognizer public class TimeSurveyBotLuisExTest { [Fact] - public void TimePeriod_ValidEntitiesInstance_ReturnsFirstDateTimeText() + public void WorkedDuration_ValidEntitiesInstance_ReturnsFirstDateTimeText() { const string timePeriod = "from 01 July to 10 July"; var instances = new TimeSurveyBotLuis._Entities._Instance @@ -36,12 +39,12 @@ public void TimePeriod_ValidEntitiesInstance_ReturnsFirstDateTimeText() _instance = instances } }; - + luisResult.WorkedDuration().Should().Be(timePeriod); } - + [Fact] - public void TimePeriod_NullOrEmptyDateTimeInstance_ThrowsException() + public void WorkedDuration_NullOrEmptyDateTimeInstance_ThrowsException() { var emptyDateTimeTextEntities = new TimeSurveyBotLuis._Entities._Instance { @@ -61,7 +64,7 @@ public void TimePeriod_NullOrEmptyDateTimeInstance_ThrowsException() _instance = emptyDateTimeTextEntities } }; - + var nullDateTimeInstance = new TimeSurveyBotLuis._Entities._Instance { datetime = null @@ -80,12 +83,12 @@ public void TimePeriod_NullOrEmptyDateTimeInstance_ThrowsException() _instance = new TimeSurveyBotLuis._Entities._Instance() } }; - - Func getDateTimeWithNullDateTimeEntities = () => lsEmptyDateTimeTextInstance.WorkedDuration(); + + Func getDateTimeWithNullDateTimeEntities = () => lsEmptyDateTimeTextInstance.WorkedDuration(); getDateTimeWithNullDateTimeEntities.Should().ThrowExactly() .WithMessage("No worked duration has been recognized"); - - Func getDateTimeWithEmptyEntities = () => lsEmptyInstance.WorkedDuration(); + + Func getDateTimeWithEmptyEntities = () => lsEmptyInstance.WorkedDuration(); getDateTimeWithEmptyEntities.Should().ThrowExactly() .WithMessage("No worked duration has been recognized"); @@ -95,7 +98,7 @@ public void TimePeriod_NullOrEmptyDateTimeInstance_ThrowsException() } [Fact] - public void TimePeriodInMinutes_EightHoursPeriod_ReturnsMinutes() + public void WorkedDurationInMinutes_EightHoursPeriod_ReturnsMinutes() { var instances = new TimeSurveyBotLuis._Entities._Instance { @@ -117,9 +120,351 @@ public void TimePeriodInMinutes_EightHoursPeriod_ReturnsMinutes() }; const double expectedMinutes = 480.00; - + luisResult.WorkedDurationInMinutes().Should().Be(expectedMinutes); } - + + [Fact] + public void WorkedPeriod_DateWithoutTime_ReturnsWorkedPeriodFromNineAm() + { + var mondayFirstNovember = new DateTime(2021, 11, 8, 15, 0, 0); + var lastFridayStart = new DateTime(2021, 11, 5, 6, 0, 0); + var lastFridayEnd = new DateTime(2021, 11, 5, 8, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "last friday", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(lastFridayStart); + end.Should().Be(lastFridayEnd); + } + + [Fact] + public void WorkedPeriod_PeriodStartingFromDateTime_ReturnsWorkedPeriod() + { + var mondayFirstNovember = new DateTime(2021, 11, 8, 15, 0, 0); + var lastFridayStart = new DateTime(2021, 11, 5, 13, 0, 0); + var lastFridayEnd = new DateTime(2021, 11, 5, 15, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "last friday at 4pm", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(lastFridayStart); + end.Should().Be(lastFridayEnd); + } + + [Fact] + public void WorkedPeriod_TillSelectedHour_ReturnsWorkedPeriod() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var expectedStart = new DateTime(2021, 11, 1, 13, 0, 0); + var expectedEnd = new DateTime(2021, 11, 1, 15, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "till 6 pm", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(expectedStart); + end.Should().Be(expectedEnd); + } + + [Fact] + public void WorkedPeriod_FromSelectedHour_ReturnsWorkedPeriod() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var expectedStart = new DateTime(2021, 11, 1, 13, 0, 0); + var expectedEnd = new DateTime(2021, 11, 1, 15, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "from 4 pm", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(expectedStart); + end.Should().Be(expectedEnd); + } + + [Fact] + public void WorkedPeriod_FromToHoursRange_ReturnsWorkedPeriod() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var expectedStart = new DateTime(2021, 11, 1, 6, 0, 0); + var expectedEnd = new DateTime(2021, 11, 1, 8, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "from 9 am to 11 am", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(expectedStart); + end.Should().Be(expectedEnd); + } + + [Fact] + public void WorkedPeriod_HoursRangeMismatchWithDuration_ThrowsInvalidWorkedPeriodException() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "1 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "from 9 am to 11 am", + Type = "builtin.datetimeV2.datetime" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + Func<(DateTime, DateTime)> action = () => + luisResult.WorkedPeriod(mockDateTimeProvider.Object, 60, timeZonePlusThree); + + action.Should().ThrowExactly() + .WithMessage("Worked period time span differs from the duration provided. Expected 60 but got 120"); + } + + [Fact] + public void WorkedPeriod_DateTimeIsDuration_ThrowsInvalidWorkedPeriodException() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "1 hours", + Type = "builtin.datetimeV2.duration" + }, + new InstanceData + { + Text = "for 60 minutes", + Type = "builtin.datetimeV2.duration" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + Func<(DateTime, DateTime)> action = () => + luisResult.WorkedPeriod(mockDateTimeProvider.Object, 60, timeZonePlusThree); + + action.Should().ThrowExactly() + .WithMessage("Date time type duration is not allowed"); + } + + [Fact] + public void WorkedPeriod_NoHoursRange_ReturnsWorkedPeriodStartingFromNineAm() + { + var mondayFirstNovember = new DateTime(2021, 11, 1, 16, 0, 0); + var expectedStart = new DateTime(2021, 11, 1, 6, 0, 0); + var expectedEnd = new DateTime(2021, 11, 1, 8, 0, 0); + var timeZonePlusThree = TZConvert.GetTimeZoneInfo("Europe/Moscow"); + + var mockDateTimeProvider = new Mock(); + mockDateTimeProvider.Setup(d => d.DateTimeUtcNow()) + .Returns(mondayFirstNovember); + + var instances = new TimeSurveyBotLuis._Entities._Instance + { + datetime = new[] + { + new InstanceData + { + Text = "2 hours", + Type = "builtin.datetimeV2.duration" + } + } + }; + + var luisResult = new TimeSurveyBotLuis + { + Entities = new TimeSurveyBotLuis._Entities + { + _instance = instances + } + }; + + var (start, end) = luisResult.WorkedPeriod(mockDateTimeProvider.Object, 120, + timeZonePlusThree); + + start.Should().Be(expectedStart); + end.Should().Be(expectedEnd); + } } } \ No newline at end of file