using System.Globalization; using Nonemm.Core; namespace Nonemm.Session; /// The fields N1MM's statistics window offers, in N1MM's own order. Every one /// of them can go down the rows; nine of them cannot go across the columns, /// because a column per call or per five minutes is wider than any screen. public static class StatisticsFields { private static readonly string[] NotColumns = [ "Hour", "CountryPrefix", "CK", "WPXPrefix", "MiscText", "5 Minute", "10 Minute", "15 Minute", "Call", ]; public static readonly IReadOnlyList Rows = [ new("Band", (q, _) => BandKey(q)), Text("Operator", q => q.Operator), new("Hour", (q, _) => new PivotKey( DayText(q), q.TimestampUtc.ToString("HH", CultureInfo.InvariantCulture), SecondOrder: q.TimestampUtc.Hour), "Day", "Hr"), Number("RadioNr", q => q.RadioNumber), Text("Mode", q => q.Mode.Name), Text("CountryPrefix", q => q.CountryPrefix), Number("Zone", q => q.Zone), Text("Section", q => q.Section), Number("CK", q => q.Check), Text("Prec", q => q.Precedence), Number("Points", q => q.Points), Flag("Mult1", q => q.IsMultiplier1), Flag("Mult2", q => q.IsMultiplier2), Flag("Mult3", q => q.IsMultiplier3), Text("WPXPrefix", q => q.WpxPrefix), // N1MM counts a four-character field, so every station in a square // lands in one row however precise a grid it sent Text("GridSquare", q => q.GridSquare.Length > 4 ? q.GridSquare[..4] : q.GridSquare), Number("Run1Run2", q => q.RunPosition), Text("MiscText", q => q.MiscText), Text("ContactType", q => q.ContactType), Flag("IsRunQSO", q => q.IsRunQso), new("Day", (q, _) => new PivotKey(DayText(q))), Minutes(5), Minutes(10), Minutes(15), new("Mode & Band", (q, _) => new PivotKey( q.Mode.Name, q.Band?.Name ?? "", SecondOrder: q.Band?.MegahertzLabel ?? 0), "Mode", "Band"), Text("Exchange1", q => q.Exchange1), Text("Call", q => q.Call.Text), Text("RoverLocation", q => q.RoverLocation), Text("Computer Name", q => q.StationName), ]; public static readonly IReadOnlyList Columns = Rows.Where(f => !NotColumns.Contains(f.Name)).ToList(); /// The field of that name, or the first field when there is none: a name /// read back out of the settings can be one this version no longer has. public static StatisticsField Find(IReadOnlyList among, string name) => among.FirstOrDefault(f => f.Name == name) ?? among[0]; private static PivotKey BandKey(Qso qso) => new(qso.Band?.Name ?? "", Order: qso.Band?.MegahertzLabel ?? 0); private static string DayText(Qso qso) => qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); private static StatisticsField Text(string name, Func value) => new(name, (q, _) => new PivotKey(value(q))); private static StatisticsField Number(string name, Func value) => new(name, (q, _) => { int found = value(q); return new PivotKey(found.ToString(CultureInfo.InvariantCulture), Order: found); }); /// N1MM reads the flag columns back as VB booleans, so the labels are /// `True` and `False` and False sorts first. private static StatisticsField Flag(string name, Func value) => new(name, (q, _) => value(q) ? new PivotKey("True", Order: 1) : new PivotKey("False")); /// Minutes since the contest started, in steps of `span`. N1MM works this /// out in SQLite with integer division, which cuts towards zero. private static StatisticsField Minutes(int span) => new($"{span} Minute", (q, start) => { long seconds = (long)(q.TimestampUtc - start).TotalSeconds; long bucket = span * (seconds / (span * 60)); return new PivotKey(bucket.ToString(CultureInfo.InvariantCulture), Order: bucket); }); }