diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index b6c0c28..dccd92a 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -211,6 +211,11 @@
+
+
+
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
index 5647d4e..7e223a6 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs
@@ -6,6 +6,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Contests;
+using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.App.Theming;
using Nonemm.Session;
@@ -799,6 +800,7 @@ public sealed partial class EntryWindow : Window
Title = TitleLine();
ShowCallFrame();
PathText.Text = PathLine();
+ ShowQtcInfo();
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
ShowLights();
@@ -950,6 +952,24 @@ public sealed partial class EntryWindow : Window
+ $"{path.DistanceKm:N0} km · {path.DistanceKm * MilesPerKilometre:N0} mi{sun}";
}
+ /// WAE's QTC tally for the station being worked, which N1MM keeps in a
+ /// blue label under the entry boxes: how many lines have gone with it, how
+ /// many more may go, and how many contacts are left to report to anybody.
+ /// The line is only there in WAE, because no other contest has traffic.
+ private void ShowQtcInfo()
+ {
+ if (Logging is null || Logging.Contest is not Wae)
+ {
+ QtcInfoText.IsVisible = false;
+ return;
+ }
+ string call = Logging.Entry.Call.Trim();
+ QtcCounts counts = new QtcTraffic(Logging)
+ .CountsFor(Callsign.Parse(call.Length >= 3 ? call : ""));
+ QtcInfoText.Text = counts.IsFull ? $"{counts.Text} — at the limit" : counts.Text;
+ QtcInfoText.IsVisible = true;
+ }
+
/// A blank line where a bearing should be is a puzzle. This says which of
/// the two ends could not be placed and what to do about it.
private string WhyNoPath()
diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml b/src/Nonemm.App/Windows/QtcWindow.axaml
index 597e96d..41470ee 100644
--- a/src/Nonemm.App/Windows/QtcWindow.axaml
+++ b/src/Nonemm.App/Windows/QtcWindow.axaml
@@ -11,6 +11,9 @@
+
+
diff --git a/src/Nonemm.App/Windows/QtcWindow.axaml.cs b/src/Nonemm.App/Windows/QtcWindow.axaml.cs
index 4743be3..090a334 100644
--- a/src/Nonemm.App/Windows/QtcWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/QtcWindow.axaml.cs
@@ -98,7 +98,7 @@ public sealed partial class QtcWindow : Window
return isSending
? IsRtty
? "Send All reads the whole series out — Snd n sends one QTC again"
- : $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
+ : "Nothing goes out on SSB: read the lines out yourself, then Close to log them."
: "Type the header, then a line per QTC: time, callsign, serial number.";
}
return isSending
@@ -132,6 +132,7 @@ public sealed partial class QtcWindow : Window
private void Reset()
{
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
+ CountsText.Text = traffic.CountsFor(station).Text;
KeysText.Text = KeysLine();
// N1MM's labels: the buttons say what they do in the direction the
// traffic is going, and the ones with nothing to do are hidden
diff --git a/src/Nonemm.Session/QtcTraffic.cs b/src/Nonemm.Session/QtcTraffic.cs
index 440a383..2897be8 100644
--- a/src/Nonemm.Session/QtcTraffic.cs
+++ b/src/Nonemm.Session/QtcTraffic.cs
@@ -15,6 +15,28 @@ public enum QtcDirection
Either,
}
+/// The QTC tally for one station, as N1MM writes it beside the callsign:
+/// how many lines have already gone with it, how many more may go, and how
+/// many logged contacts are left to report to anybody.
+public sealed record QtcCounts(QtcDirection Role, int Exchanged, int LeftWith, int Available)
+{
+ /// N1MM's wording, one line per role. Europe on CW and SSB only takes
+ /// traffic down, so there is no pile of contacts to report and the line
+ /// leaves that number out.
+ public string Text => Role switch
+ {
+ QtcDirection.Receive => $"QTCs: {Exchanged} rcvd/{LeftWith} left to exch.",
+ QtcDirection.Send =>
+ $"QTCs: {Exchanged} sent/{LeftWith} left to exch/{Available} total QTCs avail",
+ QtcDirection.Either =>
+ $"QTCs: {Exchanged} exch/{LeftWith} left to exch/{Available} total QTCs avail",
+ _ => "",
+ };
+
+ /// N1MM puts this in the status bar as well when a station has had its ten.
+ public bool IsFull => Exchanged >= WaeQtc.MostPerStation;
+}
+
/// The WAE QTC rules that do not need a window: who may exchange traffic with
/// whom, which contacts are still worth reporting, how the series are numbered,
/// and whether a line an operator has typed reads as a QTC.
@@ -87,6 +109,46 @@ public sealed class QtcTraffic
public int Remaining(Callsign other) =>
Math.Max(0, WaeQtc.MostPerStation - ExchangedWith(other));
+ /// Which way traffic goes here whoever is worked: Europe takes it down on
+ /// CW and SSB, the rest of the world reads it out, and on RTTY it goes
+ /// both ways. `DirectionFor` answers the same question about one station;
+ /// this one ignores the station, so the counts still read out when the
+ /// callsign box is empty or holds a station with nothing left.
+ public QtcDirection Role
+ {
+ get
+ {
+ if (position.Contest is not Wae contest)
+ {
+ return QtcDirection.None;
+ }
+ if (contest.Modes.Contains(ModeCategory.Digital))
+ {
+ return QtcDirection.Either;
+ }
+ return position.Me.Continent == "EU" ? QtcDirection.Receive : QtcDirection.Send;
+ }
+ }
+
+ /// How many logged contacts are still worth reporting to this station: the
+ /// whole pile rather than one series of ten. This is N1MM's
+ /// `QTCRecordsetCount`, which is zero for a European station on CW and SSB
+ /// because it only takes traffic down.
+ public int Available(Callsign other) =>
+ Role == QtcDirection.Receive ? 0 : Reportable(other).Count();
+
+ /// The three numbers the entry window shows. `LeftWith` is what may still
+ /// go with this station: what is free of its ten, and no more than the
+ /// contacts there are to report when we are the one reporting.
+ public QtcCounts CountsFor(Callsign other)
+ {
+ int exchanged = ExchangedWith(other);
+ int free = Math.Max(0, WaeQtc.MostPerStation - exchanged);
+ int available = Available(other);
+ int left = Role == QtcDirection.Send ? Math.Min(free, available) : free;
+ return new QtcCounts(Role, exchanged, left, available);
+ }
+
/// Series are numbered across the whole contest, not per station: the
/// hundredth series a station sends is `100/10` whoever gets it.
public int NextSeries()
@@ -111,18 +173,7 @@ public sealed class QtcTraffic
{
return [];
}
- HashSet reported = position.Log.Qsos
- .Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
- .Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber))
- .ToHashSet(StringComparer.OrdinalIgnoreCase);
- List worth = position.Log.Qsos
- .Where(q => !WaeQtc.IsQtc(q))
- .Where(q => q.ContactType.Length == 0)
- .Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
- .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)))
- .OrderBy(q => q.TimestampUtc)
- .Take(room)
- .ToList();
+ List worth = [.. Reportable(other).OrderBy(q => q.TimestampUtc).Take(room)];
int series = NextSeries();
return [.. worth.Select(q => new WaeQtc(
other,
@@ -196,6 +247,22 @@ public sealed class QtcTraffic
private static bool IsClockTime(string text) =>
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
+ /// The contacts not reported to anybody yet, without the dupes and without
+ /// the station in front of us. The caller puts them in order. What N1MM's
+ /// `QTCRecordsetCount` leaves out is the dupes.
+ private IEnumerable Reportable(Callsign other)
+ {
+ HashSet reported = position.Log.Qsos
+ .Where(q => q.Exchange1.Equals(WaeQtc.Sent, StringComparison.OrdinalIgnoreCase))
+ .Select(q => ReportedKey(q.ReceivedReport, q.ReceivedNumber))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return position.Log.Qsos
+ .Where(q => !WaeQtc.IsQtc(q))
+ .Where(q => q.ContactType.Length == 0)
+ .Where(q => !q.Call.Text.Equals(other.Text, StringComparison.OrdinalIgnoreCase))
+ .Where(q => !reported.Contains(ReportedKey(q.Call.Text, q.SentNumber)));
+ }
+
private static string ReportedKey(string call, int number) => $"{call}|{number}";
private string ContinentOf(Callsign call) =>
diff --git a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
index 4acf5b1..8abcdbf 100644
--- a/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
+++ b/tests/Nonemm.Session.Tests/QtcTrafficTests.cs
@@ -72,6 +72,26 @@ public class QtcTrafficTests
Exchange1 = WaeQtc.Contact,
});
+ /// One line of traffic with a station, as a log row of its own. What is
+ /// reported does not matter to the counts, only that the line is there.
+ private static Qso Traffic(
+ OperatingPosition position,
+ string station,
+ bool isSent,
+ int number,
+ int minute) =>
+ position.Session.Add(new WaeQtc(
+ Callsign.Parse(station), isSent, 1, 1, "1300", Callsign.Parse("W1XX"), number)
+ .ApplyTo(new Qso
+ {
+ Id = Qso.NewId(),
+ TimestampUtc = new DateTime(2026, 11, 14, 13, minute, 0, DateTimeKind.Utc),
+ Call = Callsign.Parse(station),
+ Frequency = Frequency.FromKilohertz(14_080),
+ Mode = Modes.Rtty,
+ ContestName = "WAERTTY",
+ }));
+
[Fact]
public void OnCwEuropeReceivesAndTheRestOfTheWorldSends()
{
@@ -167,6 +187,100 @@ public class QtcTrafficTests
Assert.Equal(87, traffic.NextSeries());
}
+ /// Europe on CW and SSB only takes traffic down, so there is no pile of
+ /// contacts to report and the line says how many of the ten are left.
+ [Fact]
+ public void EuropeCountsWhatItHasTakenDown()
+ {
+ (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Cw);
+ Contact(position, "K1ABC", receivedNumber: 5, sentNumber: 1, minute: 40);
+ Traffic(position, "NE1C", isSent: false, number: 7, minute: 41);
+
+ QtcCounts counts = traffic.CountsFor(Callsign.Parse("NE1C"));
+
+ Assert.Equal(QtcDirection.Receive, counts.Role);
+ Assert.Equal(1, counts.Exchanged);
+ Assert.Equal(9, counts.LeftWith);
+ Assert.Equal(0, counts.Available);
+ Assert.Equal("QTCs: 1 rcvd/9 left to exch.", counts.Text);
+ }
+
+ /// Outside Europe the traffic goes the other way, so what may still be
+ /// exchanged is capped by the contacts there are left to report.
+ [Fact]
+ public void OutsideEuropeWhatIsLeftIsCappedByWhatThereIsToReport()
+ {
+ (OperatingPosition position, QtcTraffic traffic) =
+ Station(ModeCategory.Cw, UnitedStates);
+ Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
+ Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
+ Contact(position, "DL9XYZ", receivedNumber: 7, sentNumber: 3, minute: 48);
+
+ QtcCounts counts = traffic.CountsFor(Callsign.Parse("DL9XYZ"));
+
+ Assert.Equal(QtcDirection.Send, counts.Role);
+ Assert.Equal(0, counts.Exchanged);
+ // the station in front of us is not reported to itself
+ Assert.Equal(2, counts.Available);
+ Assert.Equal(2, counts.LeftWith);
+ Assert.Equal("QTCs: 0 sent/2 left to exch/2 total QTCs avail", counts.Text);
+ }
+
+ /// A contact already reported to somebody is out of the pile, and a line
+ /// already sent to this station is off its ten.
+ [Fact]
+ public void ReportingContactsTakesThemOutOfTheCounts()
+ {
+ (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
+ Contact(position, "DL1AAA", receivedNumber: 16, sentNumber: 1, minute: 46);
+ Contact(position, "DL2BBB", receivedNumber: 40, sentNumber: 2, minute: 47);
+ WaeQtc first = traffic.ToSend(Callsign.Parse("JA1XYZ"))[0];
+ position.Session.Add(first.ApplyTo(new Qso
+ {
+ Id = Qso.NewId(),
+ TimestampUtc = new DateTime(2026, 11, 14, 13, 50, 0, DateTimeKind.Utc),
+ Call = Callsign.Parse("JA1XYZ"),
+ Frequency = Frequency.FromKilohertz(14_080),
+ Mode = Modes.Rtty,
+ ContestName = "WAERTTY",
+ }));
+
+ QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
+
+ Assert.Equal(QtcDirection.Either, counts.Role);
+ Assert.Equal(1, counts.Exchanged);
+ Assert.Equal(1, counts.Available);
+ Assert.Equal("QTCs: 1 exch/9 left to exch/1 total QTCs avail", counts.Text);
+ }
+
+ /// On RTTY traffic goes both ways, so what is left with a station is what
+ /// is free of its ten whether or not there is anything to report.
+ [Fact]
+ public void OnRttyWhatIsLeftIsNotCappedByWhatThereIsToReport()
+ {
+ (_, QtcTraffic traffic) = Station(ModeCategory.Digital);
+
+ QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
+
+ Assert.Equal(0, counts.Available);
+ Assert.Equal(10, counts.LeftWith);
+ }
+
+ [Fact]
+ public void AStationThatHasHadItsTenIsFull()
+ {
+ (OperatingPosition position, QtcTraffic traffic) = Station(ModeCategory.Digital);
+ for (int at = 1; at <= 10; at++)
+ {
+ Traffic(position, "JA1XYZ", isSent: true, number: at, minute: at);
+ }
+
+ QtcCounts counts = traffic.CountsFor(Callsign.Parse("JA1XYZ"));
+
+ Assert.True(counts.IsFull);
+ Assert.Equal(0, counts.LeftWith);
+ }
+
[Theory]
[InlineData("1346", "DL2UH", "40")]
[InlineData("0001", "2E0GYI", "1")]