From 22034956b02ed2b4d6294587b1b4b37f3dc17b0e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 18 May 2023 22:19:22 +1200 Subject: [PATCH] =?UTF-8?q?feat(ux):=20=E2=9C=A8=20implement=20reports=20c?= =?UTF-8?q?ommand=20(#2)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed commit of the following: commit 0da4d1f9f19a541ca4f0c0c337d24713066ac3e2 Author: James Date: Thu May 18 22:09:14 2023 +1200 fix(reports): :bug: fix missed name kebaberisation commit b6e4a0d89d7c5587546f24ed9e28d688a9295784 Author: James Date: Thu May 18 22:06:41 2023 +1200 feat(reports): :sparkles: implement viewing of past reports (#2) commit 934acee80240b073e2f83f59112f1ac5d2a257c7 Author: James Date: Thu May 18 19:53:47 2023 +1200 fix(reports): :speech_balloon: fix incorrect time strings when 0 (#11) commit ff8b231a11b64a65b0fd8c890b826f646cd4cee2 Merge: 34df66f 45a10c3 Author: James Date: Thu May 18 19:52:51 2023 +1200 Merge branch 'main' into tgl-view commit 34df66f90ae81125e0f7c4129b23dc6375af003f Author: James Date: Thu May 18 19:48:13 2023 +1200 refactor(reports): :rotating_light: remove `pragma` directive commit 5a3bb606657a342b83a9833e178772676bcb72b7 Merge: 94f7723 bd14f00 Author: James Date: Thu May 18 19:41:35 2023 +1200 Merge branch 'main' into tgl-view commit 94f77230cccc64765e5ca82d9683fe185baed191 Author: James Date: Thu May 18 18:47:50 2023 +1200 style: :art: add parentheses around boolean expressions commit e28ffcac49910ecff4b1eb650156a9da1dd5739c Author: James Date: Thu May 18 16:40:14 2023 +1200 refactor(reports): :truck: rename `view` -> `reports` (#2) commit a8bd335c4efcd68db839cb1878e68de436f1ac17 Author: James Date: Thu May 18 16:31:37 2023 +1200 fix(view): :bug: fix autocomplete of `projects`/`clients` sub selection commit 0d096192903befbc88a3c02ce377ea76717a11ba Merge: 2d0a793 7d2cc3f Author: James Date: Thu May 18 16:21:50 2023 +1200 Merge branch 'main' into tgl-view commit 7d2cc3ffb64513e556ebd69264e6270b6afd8225 Author: James Date: Thu May 18 16:19:36 2023 +1200 perf: :zap: use semaphores to disallow new fetch while another is in progress (#10) commit 2d0a793a23c578530192b4d07cea9cae59160dfd Author: James Date: Thu May 18 13:26:13 2023 +1200 feat(view): :card_file_box: invalidate reports caches on command actions commit 50148e5757e994842820a66ed018b8adbbc13e68 Author: James Date: Thu May 18 11:42:23 2023 +1200 fix(view): :bug: ensure total results have highest score if only one result commit be8b068114716ff4f352942e2c81c5c08b53bbc2 Author: James Date: Thu May 18 11:40:17 2023 +1200 feat(view): :lipstick: add subtitle to subtotal results with project/client name commit 8bd7bd9db7c92164e58124a6e45fd4147e33d9d7 Author: James Date: Thu May 18 11:32:48 2023 +1200 feat(view): :children_crossing: display selected clients's projects commit 6f4293e4d7812f3a78df952a33a8adb54719aa14 Author: James Date: Thu May 18 10:47:04 2023 +1200 feat(view): :children_crossing: set `AutoCompleteText` for totals result commit 7a90598c23aa431e684f1f01ec122c77b9ba770c Author: James Date: Thu May 18 10:42:18 2023 +1200 feat(view): :children_crossing: time entries `Action` passes to `start` command commit 3cfe32a3f228d0b62fdc216e47ecd5a3f2852b21 Author: James Date: Thu May 18 10:38:04 2023 +1200 fix(view): :bug: display total time tracked for selected project commit 5eebf82502755d7a3c272a91a70d2037cedfedd0 Author: James Date: Thu May 18 01:05:21 2023 +1200 feat(view): :children_crossing: display selected project's time entries commit 86d47c4c6464dc9a06e272d8d10531b7483a6f2d Author: James Date: Thu May 18 00:44:56 2023 +1200 feat(view): :children_crossing: implement searching for result title and autocomplete commit a766df3e225500e2a6357d7c5136f36a3902c734 Author: James Date: Thu May 18 00:40:53 2023 +1200 refactor: :mute: remove `cacheKey` log commit 04d0b70049a413d64c3148bc7697f2540e89ec2d Author: James Date: Thu May 18 00:23:04 2023 +1200 perf(view): :zap: do not re-create background fetches when searching for span commit 8dee4b4d512889146fef55cc383e7499c56d50c6 Merge: 5653f6d 864092b Author: James Date: Thu May 18 00:20:10 2023 +1200 Merge branch 'main' into tgl-view commit 5653f6dc65d0049d3b06f981ff2d9111b3d1f22d Author: James Date: Thu May 18 00:10:32 2023 +1200 fix(view): :bug: fix mutation of cached report data with currently running timer commit 32a5e681e102eca7aa3c3583a82df0445e71c6ee Author: James Date: Wed May 17 23:46:19 2023 +1200 feat(view): :construction: cache summary time entries requests commit 07bf4600f3ea3f38f804ed102f1fe239732aa216 Merge: b61a4ab 086bf25 Author: James Date: Wed May 17 23:29:17 2023 +1200 Merge branch 'main' into tgl-view commit b61a4abe466dd61df4fa92a944f1de21e955b06b Author: James Date: Wed May 17 22:53:38 2023 +1200 refactor(view): :pencil2: rename `timeEntries` to `summary` commit bf42efffc1824c374ae79418ea8e5ba26aa94eee Author: James Date: Wed May 17 22:51:53 2023 +1200 feat(view): :sparkles: include running timer in `tgl view` reports commit 114e8bd97d103d8e6d842b5c818c17caabe093c2 Merge: 60af653 3b0b391 Author: James Date: Wed May 17 14:34:54 2023 +1200 Merge branch 'main' into tgl-view commit 60af653099b8a243580225924dad55a0710db108 Author: James Date: Wed May 17 09:15:52 2023 +1200 feat(view): :lipstick: use most tracked project colour for client icon commit 6a701fa1fa158029c824fe9f811d4620f52e538b Author: James Date: Wed May 17 09:09:12 2023 +1200 feat(view): :sparkles: implement `tgl view entries` commit b4ed9cea9af48139487da4f2f8ddd081802dc184 Merge: eaea4a0 44d2a29 Author: James Date: Wed May 17 08:54:44 2023 +1200 Merge branch 'main' into tgl-view commit eaea4a0601ba6a66d1b663056e3b3a17070ebde0 Author: James Date: Tue May 16 23:34:29 2023 +1200 fix(view): :bug: fix year span start date calculation commit 59a0c5c0798b1a360dc1e498d2768eb0e9afd608 Author: James Date: Tue May 16 23:30:24 2023 +1200 feat(view): :sparkles: extend implementation to `tgl view clients` commit db659d20b434e0ea9a3cc4572f9e4e3ca0f80fb4 Author: James Date: Tue May 16 23:08:34 2023 +1200 feat(view): :construction: use `POST` search time entries endpoint this single endpoint should support the needs of all the different `view` sub-commands commit 48894da31f9613e68c6a3a3f75cf187a8387f757 Merge: 98f84f9 46c909d Author: James Date: Tue May 16 22:04:08 2023 +1200 Merge branch 'main' into tgl-view commit 98f84f96c02d1c22d7efc688dee53b8b6a9f72ee Author: James Date: Tue May 16 21:55:34 2023 +1200 fix(view): :bug: fix incorrect time strings when longer than 1 day define Humanizer's `maxUnit` to `Hours` commit a99d16e2947283e8abeec0a192b183c627d6cb68 Merge: 861e77d 2cc6564 Author: James Date: Tue May 16 21:54:54 2023 +1200 Merge branch 'main' into tgl-view commit 861e77d6fa1a219e73a31ba00ec57e7851075853 Author: James Date: Tue May 16 16:20:03 2023 +1200 perf: :zap: avoid reconstructing query string with `string.Join()` when possible commit 67f9d564d1696d75bf617131d5187759c94befae Author: James Date: Tue May 16 14:23:23 2023 +1200 refactor: :art: remove hardcoded literal indices for command arguments commit 6bb5d8fea45fdd4c8eee3618f03341f49c3d8273 Author: James Date: Tue May 16 12:18:03 2023 +1200 feat(view): :children_crossing: rank groupings as projects -> clients -> entries commit 3c5b2f0f86be2819a7f32eaa5c4839f7a99e38b7 Author: James Date: Tue May 16 10:44:03 2023 +1200 feat(view): :sparkles: finish rough implementation of `tgl view day projects` commit 217f8f11149d637b30a6479ff565b63ed16a2280 Author: James Date: Tue May 16 09:46:02 2023 +1200 fix(view): :bug: set `CommandArgument` members to be `init` only commit cdfdf430090980601e4d951c4621742f998fc290 Author: James Date: Tue May 16 09:34:03 2023 +1200 refactor(view): :recycle: rename durations -> spans commit 4c78d7206095ae5a23f55587072db619640f9093 Author: James Date: Mon May 15 23:58:32 2023 +1200 feat(toggl): :bug: use reports api base url commit 8e4d33c9833635d72b4c17fa567f695416ce0666 Author: James Date: Mon May 15 23:52:58 2023 +1200 fix(toggl): :bug: `start_date` may not be null commit f80162180bb91e8c92096c86476451e6274637e8 Author: James Date: Mon May 15 23:47:29 2023 +1200 feat(view): :construction: use `Dictionary` for collection of `CommandArgument`s commit 091f5be85e426de1b9e064cb580c5ed4427f6b0c Author: James Date: Mon May 15 23:25:46 2023 +1200 feat(toggl): :sparkles: implement list project users reports api method commit d8e2ca21d9c066e22f28d65ba27ab30ea2a92b9b Author: James Date: Mon May 15 20:29:03 2023 +1200 feat(view): :construction: implement report grouping selection commit 742e59de6617a06ebc9f76da57d960ae49822441 Merge: 5d663a7 fbba79e Author: James Date: Mon May 15 20:15:49 2023 +1200 Merge branch 'main' into tgl-view commit 5d663a766ae856a82d234ba2acf9ef1b89732fa8 Author: James Date: Mon May 15 20:15:07 2023 +1200 refactor(view): :recycle: refactor `CommandArgument` configuration class commit e2f42869b2c5be4e20149f2de520b9d0de825724 Author: James Date: Mon May 15 11:24:49 2023 +1200 feat(view): :construction: implement foundations of `tgl view` command (#2) commit f7c301cead751bc70392e281a122193fcd731057 Author: James Date: Mon May 15 11:24:20 2023 +1200 chore(assets): :bento: add view icon --- assets/reports.png | Bin 0 -> 17907 bytes assets/svg/reports.svg | 29 ++ src/Main.cs | 12 +- src/Settings.cs | 187 ++++++++++ src/Toggl/Client.cs | 74 ++-- src/Toggl/Responses.cs | 26 +- src/TogglTrack.cs | 789 +++++++++++++++++++++++++++++++++++++---- 7 files changed, 1028 insertions(+), 89 deletions(-) create mode 100644 assets/reports.png create mode 100644 assets/svg/reports.svg diff --git a/assets/reports.png b/assets/reports.png new file mode 100644 index 0000000000000000000000000000000000000000..a70938da4067d9ac9d7d249754c3357201311f0e GIT binary patch literal 17907 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 + + + + + + + + + + + + + + + + + diff --git a/src/Main.cs b/src/Main.cs index 27cfa6a..670e582 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -1,5 +1,6 @@ using System.Windows.Controls; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Flow.Launcher.Plugin.TogglTrack.ViewModels; @@ -18,6 +19,14 @@ public class Main : IAsyncPlugin, ISettingProvider internal TogglTrack? _togglTrack; + public static string ExtractFromQuery(Query query, int index) + { + return (index == 1) + // Expect slight performance improvement by using query.SecondToEndSearch directly + ? query.SecondToEndSearch + : string.Join(" ", query.SearchTerms.Skip(index)); + } + /// /// Runs on plugin initialisation. /// Expensive operations should be performed here. @@ -72,13 +81,14 @@ public async Task> QueryAsync(Query query, CancellationToken token) return await this._togglTrack.GetDefaultHotKeys(); } - return query.FirstSearch.ToLower() switch + return (query.FirstSearch.ToLower()) switch { Settings.StartCommand => await this._togglTrack.RequestStartEntry(token, query), Settings.EditCommand => await this._togglTrack.RequestEditEntry(token, query), Settings.StopCommand => await this._togglTrack.RequestStopEntry(token, query), Settings.DeleteCommand => await this._togglTrack.RequestDeleteEntry(token), Settings.ContinueCommand => await this._togglTrack.RequestContinueEntry(token, query), + Settings.ReportsCommand => await this._togglTrack.RequestViewReports(token, query), _ => (await this._togglTrack.GetDefaultHotKeys()) .FindAll(result => { diff --git a/src/Settings.cs b/src/Settings.cs index ad7633c..16e9fd6 100644 --- a/src/Settings.cs +++ b/src/Settings.cs @@ -1,3 +1,7 @@ +using System; +using System.Text.RegularExpressions; +using System.Collections.Generic; + namespace Flow.Launcher.Plugin.TogglTrack { /// @@ -10,15 +14,198 @@ public class Settings internal const string StopCommand = "stop"; internal const string DeleteCommand = "delete"; internal const string ContinueCommand = "continue"; + internal const string ReportsCommand = "reports"; internal const string BrowserCommand = "browser"; internal const string RefreshCommand = "refresh"; internal const string EditProjectFlag = "-p"; internal const string TimeSpanFlag = "-t"; + internal enum ReportsSpanKeys + { + Day, + Week, + Month, + Year, + } + internal static readonly Regex ReportsSpanOffsetRegex = new Regex(@"-(\d+)"); + internal static readonly List ReportsSpanArguments = new List + { + new ReportsSpanCommandArgument + { + Argument = "day", + Interpolation = offset => + { + switch (offset) + { + case (0): + { + return "today"; + } + case (1): + { + return "yesterday"; + } + default: + { + return $"{offset} days ago"; + } + } + }, + Score = 400, + // Offsetted day + Start = (referenceDate, offset) => referenceDate.AddDays(-offset), + End = (referenceDate, offset) => referenceDate.AddDays(-offset), + }, + new ReportsSpanCommandArgument + { + Argument = "week", + Interpolation = offset => + { + switch (offset) + { + case (0): + { + return "this week"; + } + case (1): + { + return "last week"; + } + default: + { + return $"{offset} weeks ago"; + } + } + }, + Score = 300, + // Monday of the offsetted week + Start = (referenceDate, offset) => referenceDate.AddDays((-(int)referenceDate.DayOfWeek + 1) - (7 * offset)), + // Sunday of the offsetted week + End = (referenceDate, offset) => referenceDate.AddDays((-(int)referenceDate.DayOfWeek + 7) - (7 * offset)), + }, + new ReportsSpanCommandArgument + { + Argument = "month", + Interpolation = offset => + { + switch (offset) + { + case (0): + { + return "this month"; + } + case (1): + { + return "last month"; + } + default: + { + return $"{offset} months ago"; + } + } + }, + Score = 200, + // First day of the offsetted month + Start = (referenceDate, offset) => new DateTimeOffset(referenceDate.Year, referenceDate.Month, 1, 0, 0, 0, referenceDate.Offset).AddMonths(-offset), + // Last day of the offsetted month + End = (referenceDate, offset) => new DateTimeOffset(referenceDate.Year, referenceDate.Month, DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month), 0, 0, 0, referenceDate.Offset).AddMonths(-offset), + }, + new ReportsSpanCommandArgument + { + Argument = "year", + Interpolation = offset => + { + switch (offset) + { + case (0): + { + return "this year"; + } + case (1): + { + return "last year"; + } + default: + { + return $"{offset} years ago"; + } + } + }, + Score = 100, + // First day of the offsetted year + Start = (referenceDate, offset) => new DateTimeOffset(referenceDate.Year - offset, 1, 1, 0, 0, 0, referenceDate.Offset), + // Last day of the offsetted year + End = (referenceDate, offset) => new DateTimeOffset(referenceDate.Year - offset, 1, 1, 0, 0, 0, referenceDate.Offset), + }, + }; + + public enum ReportsGroupingKeys + { + Projects, + Clients, + Entries, + } + private const string ReportsGroupingProjectsArgument = "projects"; + private const string ReportsGroupingClientsArgument = "clients"; + private const string ReportsGroupingEntriesArgument = "entries"; + internal static readonly List ReportsGroupingArguments = new List + { + new ReportsGroupingCommandArgument + { + Argument = Settings.ReportsGroupingProjectsArgument, + Interpolation = "View tracked time grouped by project", + Score = 300, + Grouping = Settings.ReportsGroupingKeys.Projects, + SubArgument = null, + }, + new ReportsGroupingCommandArgument + { + Argument = Settings.ReportsGroupingClientsArgument, + Interpolation = "View tracked time grouped by client", + Score = 200, + Grouping = Settings.ReportsGroupingKeys.Clients, + SubArgument = Settings.ReportsGroupingProjectsArgument, + }, + new ReportsGroupingCommandArgument + { + Argument = Settings.ReportsGroupingEntriesArgument, + Interpolation = "View tracked time entries", + Score = 100, + Grouping = Settings.ReportsGroupingKeys.Entries, + SubArgument = null, + }, + }; + /// /// Toggl Track API Token. /// public string ApiToken { get; set; } = string.Empty; } + + public class CommandArgument + { + #nullable disable + public string Argument { get; init; } + public string Interpolation { get; init; } + public int Score { get; init; } + #nullable enable + } + + public class ReportsSpanCommandArgument : CommandArgument + { + #nullable disable + public new Func Interpolation { get; init; } + public Func Start { get; init; } + public Func End { get; init; } + #nullable enable + } + + public class ReportsGroupingCommandArgument : CommandArgument + { + #nullable disable + public Settings.ReportsGroupingKeys Grouping { get; init; } + public string SubArgument { get; init; } + #nullable enable + } } \ No newline at end of file diff --git a/src/Toggl/Client.cs b/src/Toggl/Client.cs index 17c4593..98b27e7 100644 --- a/src/Toggl/Client.cs +++ b/src/Toggl/Client.cs @@ -8,18 +8,26 @@ namespace Flow.Launcher.Plugin.TogglTrack.TogglApi public class TogglClient { private readonly static string _baseUrl = "https://api.track.toggl.com/api/v9/"; + private readonly static string _reportsUrl = "https://api.track.toggl.com/reports/api/v3/"; private readonly AuthenticatedFetch _api; + private readonly AuthenticatedFetch _reportsApi; public TogglClient(string token) { this._api = new AuthenticatedFetch(token, TogglClient._baseUrl); + this._reportsApi = new AuthenticatedFetch(token, TogglClient._reportsUrl); } public void UpdateToken(string token) { this._api.UpdateToken(token); + this._reportsApi.UpdateToken(token); } + /* + * Standard API + */ + public async Task GetMe() { return await this._api.Get("me?with_related_data=true"); @@ -42,16 +50,16 @@ public void UpdateToken(string token) : DateTimeOffset.UtcNow; return await this._api.Post($"workspaces/{workspaceId}/time_entries", new - { - billable, - created_with = "flow-toggl-plugin", - description, - duration = -1 * dateTimeOffset.ToUnixTimeSeconds(), - project_id = projectId ?? default(long?), - start = dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ssZ"), - tags, - workspace_id = workspaceId, - }); + { + billable, + created_with = "flow-toggl-plugin", + description, + duration = -1 * dateTimeOffset.ToUnixTimeSeconds(), + project_id = projectId ?? default(long?), + start = dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ssZ"), + tags, + workspace_id = workspaceId, + }); } public async Task EditTimeEntry(TimeEntry timeEntry, long? projectId, string? description, DateTimeOffset? start, DateTimeOffset? stop) @@ -67,17 +75,17 @@ public void UpdateToken(string token) } return await this._api.Put($"workspaces/{timeEntry.workspace_id}/time_entries/{timeEntry.id}", new - { - timeEntry.billable, - created_with = "flow-toggl-plugin", - description, - duration, - project_id = projectId, - start = start?.ToString("yyyy-MM-ddTHH:mm:ssZ"), - stop = stop?.ToString("yyyy-MM-ddTHH:mm:ssZ"), - timeEntry.tags, - timeEntry.workspace_id, - }); + { + timeEntry.billable, + created_with = "flow-toggl-plugin", + description, + duration, + project_id = projectId, + start = start?.ToString("yyyy-MM-ddTHH:mm:ssZ"), + stop = stop?.ToString("yyyy-MM-ddTHH:mm:ssZ"), + timeEntry.tags, + timeEntry.workspace_id, + }); } public async Task GetRunningTimeEntry() @@ -109,5 +117,29 @@ public void UpdateToken(string token) { return await this._api.Get>($"me/time_entries"); } + + /* + * Reports API + */ + + public async Task GetSummaryTimeEntries(long workspaceId, long userId, Settings.ReportsGroupingKeys reportGrouping, DateTimeOffset start, DateTimeOffset? end) + { + (string grouping, string sub_grouping) = (reportGrouping) switch + { + Settings.ReportsGroupingKeys.Projects => ("projects", "time_entries"), + Settings.ReportsGroupingKeys.Clients => ("clients", "projects"), + Settings.ReportsGroupingKeys.Entries => ("projects", "time_entries"), + _ => ("projects", "time_entries"), + }; + + return await this._reportsApi.Post($"workspace/{workspaceId}/summary/time_entries", new + { + user_ids = new long[] { userId }, + start_date = start.ToString("yyyy-MM-dd"), + end_date = end?.ToString("yyyy-MM-dd"), + grouping, + sub_grouping, + }); + } } } diff --git a/src/Toggl/Responses.cs b/src/Toggl/Responses.cs index bd6a57e..0c281c6 100644 --- a/src/Toggl/Responses.cs +++ b/src/Toggl/Responses.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; namespace Flow.Launcher.Plugin.TogglTrack.TogglApi { @@ -14,7 +15,7 @@ public class Me // public string? email { get; set; } // public string? fullname { get; set; } // public bool? has_password { get; set; } - // public long? id { get; set; } + public long id { get; set; } // public string? image_url { get; set; } // public string? intercom_hash { get; set; } // public List? oath_providers { get; set; } @@ -121,7 +122,6 @@ public class Tag // public long? workspace_id { get; set; } } - public class TimeEntry { // public string? at { get; set; } @@ -144,4 +144,26 @@ public class TimeEntry // public long? wid { get; set; } public long workspace_id { get; set; } } + + public class SummaryTimeEntry + { + public List? groups { get; set; } + } + + public class SummaryTimeEntryGroup + { + public long? id { get; set; } + public List? sub_groups { get; set; } + public long seconds + { + get => this?.sub_groups?.Sum(subGroup => subGroup.seconds) ?? 0; + } + } + + public class SummaryTimeEntrySubGroup + { + public long? id { get; set; } + public string? title { get; set; } + public long seconds { get; set; } + } } \ No newline at end of file diff --git a/src/TogglTrack.cs b/src/TogglTrack.cs index 860eebd..daba6b5 100644 --- a/src/TogglTrack.cs +++ b/src/TogglTrack.cs @@ -1,5 +1,6 @@ using System; using System.Text.RegularExpressions; +using System.Text.Json; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -20,8 +21,10 @@ internal class TogglTrack private readonly (SemaphoreSlim Token, SemaphoreSlim Me, SemaphoreSlim RunningTimeEntries, SemaphoreSlim TimeEntries) _semaphores = (new SemaphoreSlim(1, 1), new SemaphoreSlim(1, 1), new SemaphoreSlim(1, 1), new SemaphoreSlim(1, 1)); private NullableCache _cache = new NullableCache(); + private List _summaryTimeEntriesCacheKeys = new List(); private long? _selectedProjectId = -1; + private long? _selectedClientId = -1; private enum EditProjectState { @@ -138,14 +141,48 @@ internal TogglTrack(PluginInitContext context, Settings settings) } } - internal void RefreshCache() + private async ValueTask _GetSummaryTimeEntries(long workspaceId, long userId, Settings.ReportsGroupingKeys reportGrouping, DateTimeOffset start, DateTimeOffset? end, bool force = false) + { + string cacheKey = $"SummaryTimeEntries{workspaceId}{userId}{(int)reportGrouping}{start.ToString("yyyy-MM-dd")}{end?.ToString("yyyy-MM-dd")}"; + + if (!force && this._cache.Contains(cacheKey)) + { + return (SummaryTimeEntry?)this._cache.Get(cacheKey); + } + + try + { + this._context.API.LogInfo("TogglTrack", "Fetching summary time entries for reports", "_GetSummaryTimeEntries"); + + var summary = await this._client.GetSummaryTimeEntries(workspaceId, userId, reportGrouping, start, end); + + this._cache.Set(cacheKey, summary, DateTimeOffset.Now.AddSeconds(30)); + this._summaryTimeEntriesCacheKeys.Add(cacheKey); + + return summary; + } + catch (Exception exception) + { + this._context.API.LogException("TogglTrack", "Failed to fetch summary time entries for reports", exception, "_GetSummaryTimeEntries"); + return null; + } + } + + private void _ClearSummaryTimeEntriesCache() + { + this._summaryTimeEntriesCacheKeys.ForEach(key => this._cache.Remove(key)); + this._summaryTimeEntriesCacheKeys.Clear(); + } + + internal void RefreshCache(bool refreshMe = false) { _ = Task.Run(() => { // This is the main one that needs to be run - _ = this._GetMe(true); + _ = this._GetMe(refreshMe); _ = this._GetRunningTimeEntry(true); _ = this._GetTimeEntries(true); + this._ClearSummaryTimeEntriesCache(); }); } @@ -174,8 +211,14 @@ internal async ValueTask VerifyApiToken() this._lastToken.IsValid = (await this._GetMe(true))?.api_token?.Equals(this._settings.ApiToken) ?? false; this._lastToken.Token = this._settings.ApiToken; - + this._semaphores.Token.Release(); + + if (this._lastToken.IsValid) + { + this.RefreshCache(true); + } + return this._lastToken.IsValid; } @@ -252,6 +295,7 @@ internal List NotifyUnknownError() internal async ValueTask> GetDefaultHotKeys() { this._selectedProjectId = -1; + this._selectedClientId = -1; this._editProjectState = TogglTrack.EditProjectState.NoProjectChange; var results = new List @@ -283,6 +327,19 @@ internal async ValueTask> GetDefaultHotKeys() }, }, new Result + { + Title = Settings.ReportsCommand, + SubTitle = "View tracked time reports", + IcoPath = "reports.png", + AutoCompleteText = $"{this._context.CurrentPluginMetadata.ActionKeyword} {Settings.ReportsCommand} ", + Score = 5, + Action = c => + { + this._context.API.ChangeQuery($"{this._context.CurrentPluginMetadata.ActionKeyword} {Settings.ReportsCommand} "); + return false; + }, + }, + new Result { Title = Settings.BrowserCommand, SubTitle = "Open Toggl Track in browser", @@ -304,7 +361,7 @@ internal async ValueTask> GetDefaultHotKeys() Score = -100, Action = c => { - this.RefreshCache(); + this.RefreshCache(true); return true; }, }, @@ -372,7 +429,14 @@ internal async ValueTask> RequestStartEntry(CancellationToken token return this.NotifyUnknownError(); } - if (query.SearchTerms.Length == 1) + var ArgumentIndices = new + { + Command = 0, + Project = 1, + Description = 2, + }; + + if (query.SearchTerms.Length == ArgumentIndices.Project) { this._selectedProjectId = -1; @@ -428,11 +492,12 @@ internal async ValueTask> RequestStartEntry(CancellationToken token ); } - return (string.IsNullOrWhiteSpace(query.SecondToEndSearch)) + string projectQuery = Main.ExtractFromQuery(query, ArgumentIndices.Project); + return (string.IsNullOrWhiteSpace(projectQuery)) ? projects : projects.FindAll(result => { - return this._context.API.FuzzySearch(query.SecondToEndSearch, $"{result.Title} {Regex.Replace(result.SubTitle, @"(?: \| )?\d+ hours?$", string.Empty)}").Score > 0; + return this._context.API.FuzzySearch(projectQuery, $"{result.Title} {Regex.Replace(result.SubTitle, @"(?: \| )?\d+ hours?$", string.Empty)}").Score > 0; }); } @@ -447,7 +512,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token ? $"{project.name}{clientName}" : "No Project"; - string description = string.Join(" ", query.SearchTerms.Skip(2)); + string description = Main.ExtractFromQuery(query, ArgumentIndices.Description); var results = new List { @@ -478,11 +543,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token this._context.API.ShowMsg($"Started {createdTimeEntry.description}", projectName, "start.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -521,7 +582,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token try { var startTimeSpan = TimeSpanParser.Parse( - string.Join(" ", query.SearchTerms.Skip(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1)), + Main.ExtractFromQuery(query, Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1), new TimeSpanParserOptions { UncolonedDefault = Units.Minutes, @@ -533,7 +594,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token var startTime = DateTimeOffset.UtcNow + startTimeSpan; // Remove -t flag from description - string sanitisedDescription = string.Join(" ", query.SearchTerms.Take(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag)).Skip(2)); + string sanitisedDescription = string.Join(" ", query.SearchTerms.Take(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag)).Skip(ArgumentIndices.Description)); results.Add(new Result { @@ -562,11 +623,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token this._context.API.ShowMsg($"Started {createdTimeEntry.description}{((string.IsNullOrEmpty(sanitisedDescription) ? string.Empty : " "))}{startTime.Humanize()}", projectName, "start.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -649,11 +706,7 @@ internal async ValueTask> RequestStartEntry(CancellationToken token this._context.API.ShowMsg($"Started {createdTimeEntry.description}{((string.IsNullOrEmpty(description) ? string.Empty : " "))}at previous stop time", projectName, "start.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -706,8 +759,17 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, }; } + var ArgumentIndices = new + { + Command = 0, + // If it exists + Project = 1, + DescriptionWithoutProject = 1, + DescriptionWithProject = 2, + }; + // Reset project selection if query emptied to 'tgl edit ' - if (query.SearchTerms.Length == 1 && this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) + if (query.SearchTerms.Length == (ArgumentIndices.Command + 1) && this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) { this._selectedProjectId = -1; this._editProjectState = TogglTrack.EditProjectState.NoProjectChange; @@ -774,11 +836,12 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, ); } - return (string.IsNullOrWhiteSpace(query.SecondToEndSearch)) + string projectQuery = Main.ExtractFromQuery(query, ArgumentIndices.Project); + return (string.IsNullOrWhiteSpace(projectQuery)) ? projects : projects.FindAll(result => { - return this._context.API.FuzzySearch(query.SecondToEndSearch, $"{result.Title} {Regex.Replace(result.SubTitle, @"(?: \| )?\d+ hours?$", string.Empty)}").Score > 0; + return this._context.API.FuzzySearch(projectQuery, $"{result.Title} {Regex.Replace(result.SubTitle, @"(?: \| )?\d+ hours?$", string.Empty)}").Score > 0; }); } @@ -795,9 +858,12 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, ? $"{project.name}{clientName}" : "No Project"; - string description = (this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) - ? string.Join(" ", query.SearchTerms.Skip(2)) - : query.SecondToEndSearch; + string description = Main.ExtractFromQuery( + query, + (this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) + ? ArgumentIndices.DescriptionWithProject + : ArgumentIndices.DescriptionWithoutProject + ); var results = new List { @@ -827,11 +893,7 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, this._context.API.ShowMsg($"Edited {editedTimeEntry.description}", $"{projectName} | {(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")}", "edit.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -871,7 +933,7 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, try { var startTimeSpan = TimeSpanParser.Parse( - string.Join(" ", query.SearchTerms.Skip(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1)), + Main.ExtractFromQuery(query, Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1), new TimeSpanParserOptions { UncolonedDefault = Units.Minutes, @@ -884,11 +946,16 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, var newElapsed = elapsed.Subtract(startTimeSpan); // Remove -t flag from description - string sanitisedDescription = string.Join(" ", query.SearchTerms.Take(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag)).Skip( - (this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) - ? 2 - : 1 - )); + string sanitisedDescription = string.Join( + " ", + query.SearchTerms + .Take(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag)) + .Skip( + (this._editProjectState == TogglTrack.EditProjectState.ProjectSelected) + ? ArgumentIndices.DescriptionWithProject + : ArgumentIndices.DescriptionWithoutProject + ) + ); results.Add(new Result { @@ -916,11 +983,7 @@ internal async ValueTask> RequestEditEntry(CancellationToken token, this._context.API.ShowMsg($"Edited {editedTimeEntry.description}", $"{projectName} | {(int)newElapsed.TotalHours}:{newElapsed.ToString(@"mm\:ss")}", "edit.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -1052,11 +1115,7 @@ internal async ValueTask> RequestStopEntry(CancellationToken token, this._context.API.ShowMsg($"Stopped {stoppedTimeEntry.description}", $"{(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")} elapsed", "stop.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -1092,7 +1151,7 @@ internal async ValueTask> RequestStopEntry(CancellationToken token, try { var stopTimeSpan = TimeSpanParser.Parse( - string.Join(" ", query.SearchTerms.Skip(Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1)), + Main.ExtractFromQuery(query, Array.IndexOf(query.SearchTerms, Settings.TimeSpanFlag) + 1), new TimeSpanParserOptions { UncolonedDefault = Units.Minutes, @@ -1136,11 +1195,7 @@ internal async ValueTask> RequestStopEntry(CancellationToken token, this._context.API.ShowMsg($"Stopped {stoppedTimeEntry.description}", $"{(int)newElapsed.TotalHours}:{newElapsed.ToString(@"mm\:ss")} elapsed", "stop.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -1244,11 +1299,7 @@ internal async ValueTask> RequestDeleteEntry(CancellationToken toke this._context.API.ShowMsg($"Deleted {runningTimeEntry.description}", $"{(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")} elapsed", "delete.png"); // Update cached running time entry state - _ = Task.Run(() => - { - _ = this._GetRunningTimeEntry(true); - _ = this._GetTimeEntries(true); - }); + this.RefreshCache(); } catch (Exception exception) { @@ -1294,6 +1345,12 @@ internal async ValueTask> RequestContinueEntry(CancellationToken to }; } + var ArgumentIndices = new + { + Command = 0, + Description = 1, + }; + var entries = timeEntries.ConvertAll(timeEntry => { var elapsed = (timeEntry.duration < 0) @@ -1329,11 +1386,613 @@ internal async ValueTask> RequestContinueEntry(CancellationToken to }; }); - return (string.IsNullOrWhiteSpace(query.SecondToEndSearch)) + string entriesQuery = Main.ExtractFromQuery(query, ArgumentIndices.Description); + return (string.IsNullOrWhiteSpace(entriesQuery)) ? entries : entries.FindAll(result => { - return this._context.API.FuzzySearch(query.SecondToEndSearch, result.Title).Score > 0; + return this._context.API.FuzzySearch(entriesQuery, result.Title).Score > 0; + }); + } + + internal async ValueTask> RequestViewReports(CancellationToken token, Query query) + { + if (token.IsCancellationRequested) + { + this._selectedProjectId = -1; + this._selectedClientId = -1; + return new List(); + } + + var me = await this._GetMe(); + if (me is null) + { + return this.NotifyUnknownError(); + } + + var ArgumentIndices = new + { + Command = 0, + Span = 1, + Grouping = 2, + GroupingName = 3, + SubGroupingName = 4, + }; + + if (query.SearchTerms.Length == ArgumentIndices.Span) + { + // Start fetch for running time entries asynchronously in the background + _ = Task.Run(() => + { + _ = this._GetRunningTimeEntry(true); + }); + } + + else if (query.SearchTerms.Length == ArgumentIndices.GroupingName) + { + this._selectedProjectId = -1; + this._selectedClientId = -1; + } + + /* + * Report span selection --- tgl view [day | week | month | year] + */ + + if ((query.SearchTerms.Length == ArgumentIndices.Span) || !Settings.ReportsSpanArguments.Exists(span => Regex.IsMatch(query.SearchTerms[ArgumentIndices.Span], $"{span.Argument}({Settings.ReportsSpanOffsetRegex})?"))) + { + string spanQuery = Main.ExtractFromQuery(query, ArgumentIndices.Span); + string queryToSpan = string.Join(" ", query.SearchTerms.Take(ArgumentIndices.Span)); + + // Implementation of eg '-5' to set span to be 5 [days | weeks | months | years] ago + Match spanOffsetMatch = Settings.ReportsSpanOffsetRegex.Match(spanQuery); + int spanOffset = (spanOffsetMatch.Success) + ? int.Parse(spanOffsetMatch.Groups[1].Value) + : 0; + + var spans = Settings.ReportsSpanArguments.ConvertAll(span => + { + string argument = (spanOffsetMatch.Success) + ? $"{span.Argument}{spanOffsetMatch.Value}" + : span.Argument; + + return new Result + { + Title = span.Argument, + SubTitle = $"View tracked time report for {span.Interpolation(spanOffset)}", + IcoPath = "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {queryToSpan} {argument} ", + Score = span.Score, + Action = c => + { + this._context.API.ChangeQuery($"{query.ActionKeyword} {queryToSpan} {argument} ", true); + return false; + }, + }; + }); + + if (!spanOffsetMatch.Success) + { + bool queryContainsDash = spanQuery.Contains("-"); + + spans.Add((queryContainsDash) + ? new Result + { + Title = "Usage Example", + SubTitle = $"{query.ActionKeyword} {queryToSpan} -1", + IcoPath = "tip.png", + AutoCompleteText = $"{query.ActionKeyword} {queryToSpan} -1 ", + Score = 100000, + Action = c => + { + this._context.API.ChangeQuery($"{query.ActionKeyword} {queryToSpan} -1 "); + return false; + } + } + : new Result + { + Title = "Usage Tip", + SubTitle = $"Use - to view past reports", + IcoPath = "tip.png", + AutoCompleteText = $"{query.ActionKeyword} {queryToSpan} -", + Score = 1, + Action = c => + { + this._context.API.ChangeQuery($"{query.ActionKeyword} {queryToSpan} -"); + return false; + } + } + ); + } + + string sanitisedSpanQuery = Settings.ReportsSpanOffsetRegex.Replace(spanQuery, string.Empty).Replace("-", string.Empty); + + return (string.IsNullOrWhiteSpace(sanitisedSpanQuery)) + ? spans + : spans.FindAll(result => + { + return this._context.API.FuzzySearch(sanitisedSpanQuery, result.Title).Score > 0; + }); + } + + /* + * Report groupinging selection --- tgl view [duration] [projects | clients | entries] + */ + if ((query.SearchTerms.Length == ArgumentIndices.Grouping) || !Settings.ReportsGroupingArguments.Exists(grouping => grouping.Argument == query.SearchTerms[ArgumentIndices.Grouping])) + { + string queryToGrouping = string.Join(" ", query.SearchTerms.Take(ArgumentIndices.Grouping)); + + var groupings = Settings.ReportsGroupingArguments.ConvertAll(grouping => + { + return new Result + { + Title = grouping.Argument, + SubTitle = grouping.Interpolation, + IcoPath = "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {queryToGrouping} {grouping.Argument} ", + Score = grouping.Score, + Action = c => + { + this._context.API.ChangeQuery($"{query.ActionKeyword} {queryToGrouping} {grouping.Argument} ", true); + return false; + }, + }; + }); + + string groupingsQuery = Main.ExtractFromQuery(query, ArgumentIndices.Grouping); + return (string.IsNullOrWhiteSpace(groupingsQuery)) + ? groupings + : groupings.FindAll(result => + { + return this._context.API.FuzzySearch(groupingsQuery, result.Title).Score > 0; + }); + } + + string spanArgument = query.SearchTerms[ArgumentIndices.Span]; + string groupingArgument = query.SearchTerms[ArgumentIndices.Grouping]; + + var spanConfiguration = Settings.ReportsSpanArguments.Find(span => Regex.IsMatch(spanArgument, $"{span.Argument}({Settings.ReportsSpanOffsetRegex})?")); + var groupingConfiguration = Settings.ReportsGroupingArguments.Find(grouping => grouping.Argument == groupingArgument); + + if ((spanConfiguration is null) || (groupingConfiguration is null)) + { + return this.NotifyUnknownError(); + } + + Match spanArgumentOffsetMatch = Settings.ReportsSpanOffsetRegex.Match(spanArgument); + int spanArgumentOffset = (spanArgumentOffsetMatch.Success) + ? int.Parse(spanArgumentOffsetMatch.Groups[1].Value) + : 0; + + var start = spanConfiguration.Start(DateTimeOffset.Now, spanArgumentOffset); + var end = spanConfiguration.End(DateTimeOffset.Now, spanArgumentOffset); + + this._context.API.LogInfo("TogglTrack", $"{spanArgument}, {groupingArgument}, {start}, {end}", "RequestViewReports"); + + var summary = await this._GetSummaryTimeEntries(me.default_workspace_id, me.id, groupingConfiguration.Grouping, start, end); + + // Use cached time entry here to improve responsiveness + var runningTimeEntry = await this._GetRunningTimeEntry(); + var runningElapsed = (runningTimeEntry is null) + ? TimeSpan.Zero + : DateTimeOffset.UtcNow.Subtract(DateTimeOffset.Parse(runningTimeEntry.start!)); + + var total = TimeSpan.FromSeconds(summary?.groups?.Sum(group => group.seconds) ?? 0) + runningElapsed; + + var results = new List + { + new Result + { + Title = $"{total.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} tracked {spanConfiguration.Interpolation(spanArgumentOffset)} ({(int)total.TotalHours}:{total.ToString(@"mm\:ss")})", + IcoPath = "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {query.Search} ", + Score = (int)total.TotalSeconds + 1000, + }, + }; + + if ((summary is null) || (summary.groups is null)) + { + return results; + } + + switch (groupingConfiguration.Grouping) + { + case (Settings.ReportsGroupingKeys.Projects): + { + if (runningTimeEntry is not null) + { + // Perform deep copy of summary so the cache is not mutated + var serialisedSummary = JsonSerializer.Serialize(summary); + summary = JsonSerializer.Deserialize(serialisedSummary); + if ((summary is null) || (summary.groups is null)) + { + return results; + } + + var projectGroup = summary.groups.Find(group => group.id == runningTimeEntry.project_id); + var entrySubGroup = projectGroup?.sub_groups?.Find(subGroup => subGroup.title == runningTimeEntry.description); + + if (entrySubGroup is not null) + { + entrySubGroup.seconds += (int)runningElapsed.TotalSeconds; + } + else if (projectGroup?.sub_groups is not null) + { + projectGroup.sub_groups.Add(new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }); + } + else if (projectGroup is not null) + { + projectGroup.sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }; + } + else + { + summary.groups.Add(new SummaryTimeEntryGroup + { + id = runningTimeEntry.project_id, + sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }, + }); + } + } + + if (this._selectedProjectId == -1) + { + results.AddRange( + summary.groups.ConvertAll(group => + { + var project = me.projects?.Find(project => project.id == group.id); + var elapsed = TimeSpan.FromSeconds(group.seconds); + + return new Result + { + Title = project?.name ?? "No Project", + SubTitle = $"{((project?.client_id is not null) ? $"{me.clients?.Find(client => client.id == project.client_id)?.name} | " : string.Empty)}{elapsed.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} ({(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")})", + IcoPath = (project?.color is not null) + ? new ColourIcon(this._context, project.color, "reports.png").GetColourIcon() + : "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} ", + Score = (int)group.seconds, + Action = c => + { + this._selectedProjectId = project?.id; + this._context.API.ChangeQuery($"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} {project?.name?.Kebaberize() ?? "no-project"} ", true); + return false; + } + }; + }) + ); + break; + } + + var selectedProjectGroup = summary.groups.Find(group => group.id == this._selectedProjectId); + + if (selectedProjectGroup?.sub_groups is null) + { + break; + } + + var project = me.projects?.Find(project => project.id == selectedProjectGroup.id); + var client = me.clients?.Find(client => client.id == project?.client_id); + + string clientName = (client is not null) + ? $" • {client.name}" + : string.Empty; + string projectName = (project is not null) + ? $"{project.name}{clientName}" + : "No Project"; + + var subResults = selectedProjectGroup.sub_groups.ConvertAll(subGroup => + { + var elapsed = TimeSpan.FromSeconds(subGroup.seconds); + + return new Result + { + Title = (string.IsNullOrEmpty(subGroup.title)) ? "(no description)" : subGroup.title, + SubTitle = $"{elapsed.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} ({(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")})", + IcoPath = (project?.color is not null) + ? new ColourIcon(this._context, project.color, "reports.png").GetColourIcon() + : "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} {project?.name?.Kebaberize() ?? "no-project"} {((string.IsNullOrEmpty(subGroup.title)) ? "(no description)" : subGroup.title)}", + Score = (int)elapsed.TotalSeconds, + Action = c => + { + this._selectedProjectId = project?.id; + this._context.API.ChangeQuery($"{query.ActionKeyword} {Settings.StartCommand} {project?.name?.Kebaberize() ?? "no-project"} {subGroup.title}"); + return false; + }, + }; + }); + + var subTotal = TimeSpan.FromSeconds(selectedProjectGroup.seconds); + subResults.Add(new Result + { + Title = $"{subTotal.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} tracked {spanConfiguration.Interpolation(spanArgumentOffset)} ({(int)subTotal.TotalHours}:{subTotal.ToString(@"mm\:ss")})", + SubTitle = projectName, + IcoPath = "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {query.Search} ", + Score = (int)subTotal.TotalSeconds + 1000, + }); + + string subNameQuery = Main.ExtractFromQuery(query, ArgumentIndices.SubGroupingName); + return (string.IsNullOrWhiteSpace(subNameQuery)) + ? subResults + : subResults.FindAll(result => + { + return this._context.API.FuzzySearch(subNameQuery, result.Title).Score > 0; + }); + } + case (Settings.ReportsGroupingKeys.Clients): + { + if (runningTimeEntry is not null) + { + // Perform deep copy of summary so the cache is not mutated + var serialisedSummary = JsonSerializer.Serialize(summary); + summary = JsonSerializer.Deserialize(serialisedSummary); + if ((summary is null) || (summary.groups is null)) + { + return results; + } + + Project? runningProject = me.projects?.Find(project => project.id == runningTimeEntry.project_id); + + if (runningProject?.client_id is not null) + { + var clientGroup = summary.groups.Find(group => group.id == runningProject.client_id); + var projectSubGroup = clientGroup?.sub_groups?.Find(subGroup => subGroup.id == runningProject.id); + + if (projectSubGroup is not null) + { + projectSubGroup.seconds += (int)runningElapsed.TotalSeconds; + } + else if (clientGroup?.sub_groups is not null) + { + clientGroup.sub_groups.Add(new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }); + } + else if (clientGroup is not null) + { + clientGroup.sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }; + } + else + { + summary.groups.Add(new SummaryTimeEntryGroup + { + id = runningTimeEntry.project_id, + sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }, + }); + } + } + } + + if (this._selectedClientId == -1) + { + results.AddRange( + summary.groups.ConvertAll(group => + { + var client = me.clients?.Find(client => client.id == group.id); + var elapsed = TimeSpan.FromSeconds(group.seconds); + + var highestProjectId = group.sub_groups?.MaxBy(subGroup => subGroup.seconds)?.id; + var highestProject = me.projects?.Find(project => project.id == highestProjectId); + + return new Result + { + Title = client?.name ?? "No Client", + SubTitle = $"{elapsed.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} ({(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")})", + IcoPath = (highestProject?.color is not null) + ? new ColourIcon(this._context, highestProject.color, "reports.png").GetColourIcon() + : "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} ", + Score = (int)group.seconds, + Action = c => + { + this._selectedClientId = client?.id; + this._context.API.ChangeQuery($"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} {client?.name?.Kebaberize() ?? "no-client"} ", true); + return false; + } + }; + }) + ); + break; + } + + var selectedClientGroup = summary.groups.Find(group => group.id == this._selectedClientId); + + if (selectedClientGroup?.sub_groups is null) + { + break; + } + + var client = me.clients?.Find(client => client.id == selectedClientGroup.id); + + var subResults = selectedClientGroup.sub_groups.ConvertAll(subGroup => + { + var project = me.projects?.Find(project => project.id == subGroup.id); + var elapsed = TimeSpan.FromSeconds(subGroup.seconds); + + return new Result + { + Title = project?.name ?? "No Project", + SubTitle = $"{((client?.id is not null) ? $"{client?.name} | " : string.Empty)}{elapsed.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} ({(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")})", + IcoPath = (project?.color is not null) + ? new ColourIcon(this._context, project.color, "reports.png").GetColourIcon() + : "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} {client?.name?.Kebaberize() ?? "no-client"} ", + Score = (int)subGroup.seconds, + Action = c => + { + this._selectedClientId = -1; + this._selectedProjectId = project?.id; + + if (string.IsNullOrEmpty(groupingConfiguration.SubArgument)) + { + throw new Exception("Invalid ViewGroupingCommandArgument configuration: Missing 'SubArgument' field."); + } + + this._context.API.ChangeQuery($"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingConfiguration.SubArgument} {project?.name?.Kebaberize() ?? "no-project"} ", true); + return false; + } + }; + }); + + var subTotal = TimeSpan.FromSeconds(selectedClientGroup.seconds); + subResults.Add(new Result + { + Title = $"{subTotal.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} tracked {spanConfiguration.Interpolation(spanArgumentOffset)} ({(int)subTotal.TotalHours}:{subTotal.ToString(@"mm\:ss")})", + SubTitle = client?.name ?? "No Client", + IcoPath = "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {query.Search} ", + Score = (int)subTotal.TotalSeconds + 1000, + }); + + string subNameQuery = Main.ExtractFromQuery(query, ArgumentIndices.SubGroupingName); + return (string.IsNullOrWhiteSpace(subNameQuery)) + ? subResults + : subResults.FindAll(result => + { + return this._context.API.FuzzySearch(subNameQuery, result.Title).Score > 0; + }); + } + case (Settings.ReportsGroupingKeys.Entries): + { + if (runningTimeEntry is not null) + { + // Perform deep copy of summary so the cache is not mutated + var serialisedSummary = JsonSerializer.Serialize(summary); + summary = JsonSerializer.Deserialize(serialisedSummary); + if ((summary is null) || (summary.groups is null)) + { + return results; + } + + var projectGroup = summary.groups.Find(group => group.id == runningTimeEntry.project_id); + var entrySubGroup = projectGroup?.sub_groups?.Find(subGroup => subGroup.title == runningTimeEntry.description); + + if (entrySubGroup is not null) + { + entrySubGroup.seconds += (int)runningElapsed.TotalSeconds; + } + else if (projectGroup?.sub_groups is not null) + { + projectGroup.sub_groups.Add(new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }); + } + else if (projectGroup is not null) + { + projectGroup.sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }; + } + else + { + summary.groups.Add(new SummaryTimeEntryGroup + { + id = runningTimeEntry.project_id, + sub_groups = new List + { + new SummaryTimeEntrySubGroup + { + title = runningTimeEntry.description, + seconds = (int)runningElapsed.TotalSeconds, + }, + }, + }); + } + } + + summary.groups.ForEach(group => + { + if (group.sub_groups is null) + { + return; + } + + var project = me.projects?.Find(project => project.id == group.id); + var client = me.clients?.Find(client => client.id == project?.client_id); + + string clientName = (client is not null) + ? $" • {client.name}" + : string.Empty; + string projectName = (project is not null) + ? $"{project.name}{clientName}" + : "No Project"; + + results.AddRange( + group.sub_groups.ConvertAll(subGroup => + { + var elapsed = TimeSpan.FromSeconds(subGroup.seconds); + + return new Result + { + Title = (string.IsNullOrEmpty(subGroup.title)) ? "(no description)" : subGroup.title, + SubTitle = $"{projectName} | {elapsed.Humanize(minUnit: Humanizer.Localisation.TimeUnit.Second, maxUnit: Humanizer.Localisation.TimeUnit.Hour)} ({(int)elapsed.TotalHours}:{elapsed.ToString(@"mm\:ss")})", + IcoPath = (project?.color is not null) + ? new ColourIcon(this._context, project.color, "reports.png").GetColourIcon() + : "reports.png", + AutoCompleteText = $"{query.ActionKeyword} {Settings.ReportsCommand} {spanArgument} {groupingArgument} {((string.IsNullOrEmpty(subGroup.title)) ? "(no description)" : subGroup.title)}", + Score = (int)elapsed.TotalSeconds, + Action = c => + { + this._selectedProjectId = project?.id; + this._context.API.ChangeQuery($"{query.ActionKeyword} {Settings.StartCommand} {project?.name?.Kebaberize() ?? "no-project"} {subGroup.title}"); + return false; + }, + }; + }) + ); + }); + + break; + } + } + + string nameQuery = Main.ExtractFromQuery(query, ArgumentIndices.GroupingName); + return (string.IsNullOrWhiteSpace(nameQuery)) + ? results + : results.FindAll(result => + { + return this._context.API.FuzzySearch(nameQuery, result.Title).Score > 0; }); } }