diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 5c81660f..6a964324 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -172,6 +172,22 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontHistoryEmbed(sws, system.Zone)); } + [Command("frontpercent")] + public async Task SystemFrontPercent(string durationStr = "30d") + { + var system = ContextEntity ?? Context.SenderSystem; + if (system == null) throw Errors.NoSystemError; + + var duration = PluralKit.Utils.ParsePeriod(durationStr); + if (duration == null) throw Errors.InvalidDateTime(durationStr); + + var rangeEnd = SystemClock.Instance.GetCurrentInstant(); + var rangeStart = rangeEnd - duration.Value; + + var frontpercent = await Switches.GetPerMemberSwitchDuration(system, rangeEnd - duration.Value, rangeEnd); + await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontPercentEmbed(frontpercent, rangeStart.InZone(system.Zone))); + } + [Command("timezone")] [Remarks("system timezone [timezone]")] [MustHaveSystem] diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 0d2b78b5..5cd76df9 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -65,5 +65,7 @@ namespace PluralKit.Bot { public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); public static PKError ImportCancelled => new PKError("Import cancelled."); public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?"); + + public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index e960bc08..86a30b2b 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -136,5 +136,32 @@ namespace PluralKit.Bot { .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)) .Build(); } + + public async Task CreateFrontPercentEmbed(IDictionary frontpercent, ZonedDateTime startingFrom) + { + var totalDuration = SystemClock.Instance.GetCurrentInstant() - startingFrom.ToInstant(); + + var eb = new EmbedBuilder() + .WithColor(Color.Blue) + .WithFooter($"Since {Formats.ZonedDateTimeFormat.Format(startingFrom)} ({Formats.DurationFormat.Format(totalDuration)} ago)"); + + var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" + + var membersOrdered = frontpercent.OrderBy(pair => pair.Value).Take(maxEntriesToDisplay).ToList(); + foreach (var pair in membersOrdered) + { + var frac = pair.Value / totalDuration; + eb.AddField(pair.Key.Name, $"{frac*100:F0}% ({Formats.DurationFormat.Format(pair.Value)})"); + } + + if (membersOrdered.Count > maxEntriesToDisplay) + { + eb.AddField("(others)", + Formats.DurationFormat.Format(membersOrdered.Skip(maxEntriesToDisplay) + .Aggregate(Duration.Zero, (prod, next) => prod + next.Value)), true); + } + + return eb.Build(); + } } } \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 199dc27f..012794fa 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -189,13 +189,19 @@ namespace PluralKit { } } - public async Task> GetSwitches(PKSystem system, int count) + public async Task> GetSwitches(PKSystem system, int count = 9999999) { // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list // (maybe when we get caching in?) return await _connection.QueryAsync("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); } + public async Task> GetSwitchMemberIds(PKSwitch sw) + { + return await _connection.QueryAsync("select member from switch_members where switch = @Switch", + new {Switch = sw.Id}); + } + public async Task> GetSwitchMembers(PKSwitch sw) { return await _connection.QueryAsync( @@ -215,5 +221,76 @@ namespace PluralKit { { await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); } + + public struct SwitchListEntry + { + public ICollection Members; + public Duration TimespanWithinRange; + } + + public async Task> GetTruncatedSwitchList(PKSystem system, Instant periodStart, Instant periodEnd) + { + // TODO: only fetch the necessary switches here + // todo: this is in general not very efficient LOL + // returns switches in chronological (newest first) order + var switches = await GetSwitches(system); + + // we skip all switches that happened later than the range end, and taking all the ones that happened after the range start + // *BUT ALSO INCLUDING* the last switch *before* the range (that partially overlaps the range period) + var switchesInRange = switches.SkipWhile(sw => sw.Timestamp >= periodEnd).TakeWhileIncluding(sw => sw.Timestamp > periodStart).ToList(); + + // query DB for all members involved in any of the switches above and collect into a dictionary for future use + // this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary + // key used in GetPerMemberSwitchDuration below + var memberObjects = (await _connection.QueryAsync( + "select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax + new {Switches = switchesInRange.Select(sw => sw.Id).ToList()})) + .ToDictionary(m => m.Id); + + + // we create the entry objects + var outList = new List(); + + // loop through every switch that *occurred* in-range and add it to the list + // end time is the switch *after*'s timestamp - we cheat and start it out at the range end so the first switch in-range "ends" there instead of the one after's start point + var endTime = periodEnd; + foreach (var switchInRange in switchesInRange) + { + // find the start time of the switch, but clamp it to the range (only applicable to the Last Switch Before Range we include in the TakeWhileIncluding call above) + var switchStartClamped = switchInRange.Timestamp; + if (switchStartClamped < periodStart) switchStartClamped = periodStart; + + var span = endTime - switchStartClamped; + outList.Add(new SwitchListEntry + { + Members = (await GetSwitchMemberIds(switchInRange)).Select(id => memberObjects[id]).ToList(), + TimespanWithinRange = span + }); + + // next switch's end is this switch's start + endTime = switchInRange.Timestamp; + } + + return outList; + } + + public async Task> GetPerMemberSwitchDuration(PKSystem system, Instant periodStart, + Instant periodEnd) + { + var dict = new Dictionary(); + + // Sum up all switch durations for each member + // switches with multiple members will result in the duration to add up to more than the actual period range + foreach (var sw in await GetTruncatedSwitchList(system, periodStart, periodEnd)) + { + foreach (var member in sw.Members) + { + if (!dict.ContainsKey(member)) dict.Add(member, sw.TimespanWithinRange); + else dict[member] += sw.TimespanWithinRange; + } + } + + return dict; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 24871607..54241e57 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text.RegularExpressions; @@ -216,6 +217,17 @@ namespace PluralKit return null; } } + + public static IEnumerable TakeWhileIncluding(this IEnumerable list, Func predicate) + { + // modified from https://stackoverflow.com/a/6817553 + foreach(var el in list) + { + yield return el; + if (!predicate(el)) + yield break; + } + } } public static class Emojis { @@ -236,10 +248,10 @@ namespace PluralKit // a smaller duration we may only bother with showing h m or m s public static IPattern DurationFormat = new CompositePatternBuilder { - {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0}, - {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("s's'"), d => true}, {DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, - {DurationPattern.CreateWithInvariantCulture("s's'"), d => true} + {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0} }.Build(); public static IPattern LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss");