chore: release v0.26.3

This commit is contained in:
2026-08-21 23:28:55 +02:00
parent 0cd243fe00
commit d48420485f
31 changed files with 1908 additions and 164 deletions
+54
View File
@@ -0,0 +1,54 @@
package qso
import (
"testing"
"time"
)
// "When did I last add an entity" is judged against the whole log. A QSO that
// was the fifth with its country must not become the first because the four
// before it fall outside a date filter — that would announce a new one every
// time the operator changed the period.
func TestLastNewDXCCIgnoresThePeriodFilter(t *testing.T) {
firstByEntity := map[int]entityFirst{}
// Two entities, worked twice each, out of chronological order on purpose.
rows := []struct {
dxcc int
at string
call string
country string
}{
{227, "2019-03-01", "F5ABC", "France"},
{291, "2021-07-04", "W1AW", "United States"},
{227, "2024-06-01", "F6XYZ", "France"}, // later, same entity
{291, "2018-01-01", "K1ZZ", "United States"}, // EARLIER than the one above
}
for _, r := range rows {
at, _ := time.Parse("2006-01-02", r.at)
if cur, seen := firstByEntity[r.dxcc]; !seen || at.Before(cur.at) {
firstByEntity[r.dxcc] = entityFirst{at: at, call: r.call, country: r.country}
}
}
if got := firstByEntity[291].call; got != "K1ZZ" {
t.Errorf("first US contact = %q, want K1ZZ (the earliest, whatever the row order)", got)
}
if got := firstByEntity[227].call; got != "F5ABC" {
t.Errorf("first French contact = %q, want F5ABC", got)
}
// The last new entity is the NEWEST of those firsts: France in 2019, not the
// 2024 French contact, and not the 2021 US one.
var s Stats
for num, f := range firstByEntity {
if f.at.After(parseTimeLoose(s.LastNewDXCCDate)) {
s.LastNewDXCC, s.LastNewDXCCNum, s.LastNewDXCCCall = f.country, num, f.call
s.LastNewDXCCDate = f.at.Format(time.RFC3339)
}
}
if s.LastNewDXCC != "France" || s.LastNewDXCCNum != 227 || s.LastNewDXCCCall != "F5ABC" {
t.Errorf("last new entity = %q/%d/%q, want France/227/F5ABC", s.LastNewDXCC, s.LastNewDXCCNum, s.LastNewDXCCCall)
}
if s.LastNewDXCCDate[:10] != "2019-03-01" {
t.Errorf("date = %q, want 2019-03-01", s.LastNewDXCCDate)
}
}
+48
View File
@@ -137,6 +137,13 @@ const gapThreshold = 30 * time.Minute
const rateMaxHours = 7 * 24
// Stats is the whole dashboard payload.
// entityFirst is the first contact ever made with one DXCC entity.
type entityFirst struct {
at time.Time
call string
country string
}
type Stats struct {
// Headline figures.
Total int `json:"total"`
@@ -146,6 +153,19 @@ type Stats struct {
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
LastQSO string `json:"last_qso"`
// The most recent entity worked for the FIRST time, and the contact that did
// it. "When did I last add one" is the question a DX chaser asks of a log,
// and it was the one figure the panel could not answer.
//
// Judged against the WHOLE log, never the selected period: an entity is new
// once, and a QSO that was the fifth with its country does not become the
// first because the four before it fall outside a date filter. Only the
// period filter decides whether that contact is SHOWN.
LastNewDXCC string `json:"last_new_dxcc"` // country name
LastNewDXCCNum int `json:"last_new_dxcc_num"` // ADIF entity number
LastNewDXCCCall string `json:"last_new_dxcc_call"`
LastNewDXCCDate string `json:"last_new_dxcc_date"` // RFC3339, "" if none
// Confirmations (of Total).
ConfirmedLoTW int `json:"confirmed_lotw"`
ConfirmedEQSL int `json:"confirmed_eqsl"`
@@ -331,6 +351,8 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
monthC = map[string]int{}
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
// The earliest contact with each entity, for "last new DXCC".
firstByEntity = map[int]entityFirst{}
)
for rows.Next() {
@@ -346,6 +368,22 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
return s, err
}
// The first contact with each entity, over the WHOLE log — recorded before
// every filter below, because being new is a property of the log and not
// of the period on screen.
if dxcc.Valid && dxcc.Int64 > 0 {
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
n := int(dxcc.Int64)
if cur, seen := firstByEntity[n]; !seen || t.Before(cur.at) {
firstByEntity[n] = entityFirst{
at: t,
call: strings.ToUpper(strings.TrimSpace(call.String)),
country: strings.TrimSpace(country.String),
}
}
}
}
// Contest filter first — same reasoning as the window below: a QSO that
// isn't in this contest must not reach ANY bucket.
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
@@ -459,6 +497,16 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
return s, err
}
// The newest of the first-contacts: the last entity added to the log.
for num, f := range firstByEntity {
if f.at.After(parseTimeLoose(s.LastNewDXCCDate)) {
s.LastNewDXCC = f.country
s.LastNewDXCCNum = num
s.LastNewDXCCCall = f.call
s.LastNewDXCCDate = f.at.Format(time.RFC3339)
}
}
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)