Add front percent command

This commit is contained in:
Ske 2019-06-30 23:41:01 +02:00
parent 7eeaea39fe
commit 42147fd9cc
5 changed files with 138 additions and 4 deletions

View File

@ -172,6 +172,22 @@ namespace PluralKit.Bot.Commands
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontHistoryEmbed(sws, system.Zone)); 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")] [Command("timezone")]
[Remarks("system timezone [timezone]")] [Remarks("system timezone [timezone]")]
[MustHaveSystem] [MustHaveSystem]

View File

@ -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 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 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 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`.");
} }
} }

View File

@ -136,5 +136,32 @@ namespace PluralKit.Bot {
.WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)) .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid))
.Build(); .Build();
} }
public async Task<Embed> CreateFrontPercentEmbed(IDictionary<PKMember, Duration> 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();
}
} }
} }

View File

@ -189,13 +189,19 @@ namespace PluralKit {
} }
} }
public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count) public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count = 9999999)
{ {
// TODO: refactor the PKSwitch data structure to somehow include a hydrated member list // TODO: refactor the PKSwitch data structure to somehow include a hydrated member list
// (maybe when we get caching in?) // (maybe when we get caching in?)
return await _connection.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count}); return await _connection.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count});
} }
public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw)
{
return await _connection.QueryAsync<int>("select member from switch_members where switch = @Switch",
new {Switch = sw.Id});
}
public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw) public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw)
{ {
return await _connection.QueryAsync<PKMember>( return await _connection.QueryAsync<PKMember>(
@ -215,5 +221,76 @@ namespace PluralKit {
{ {
await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id}); await _connection.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id});
} }
public struct SwitchListEntry
{
public ICollection<PKMember> Members;
public Duration TimespanWithinRange;
}
public async Task<IEnumerable<SwitchListEntry>> 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<PKMember>(
"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<SwitchListEntry>();
// 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<IDictionary<PKMember, Duration>> GetPerMemberSwitchDuration(PKSystem system, Instant periodStart,
Instant periodEnd)
{
var dict = new Dictionary<PKMember, Duration>();
// 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;
}
} }
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -216,6 +217,17 @@ namespace PluralKit
return null; return null;
} }
} }
public static IEnumerable<T> TakeWhileIncluding<T>(this IEnumerable<T> list, Func<T, bool> 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 { public static class Emojis {
@ -236,10 +248,10 @@ namespace PluralKit
// a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s // a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s
public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration> public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration>
{ {
{DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0}, {DurationPattern.CreateWithInvariantCulture("s's'"), d => true},
{DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0},
{DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, {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(); }.Build();
public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); public static IPattern<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss");