feat: Winkeyer

This commit is contained in:
2026-06-02 01:17:26 +02:00
parent 2eb77370e4
commit 2b4326b553
26 changed files with 3125 additions and 645 deletions
+45 -44
View File
@@ -6,10 +6,39 @@ import (
"database/sql"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
)
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
// fields (empty string, nil pointer, zero time, zero number) are skipped so
// existing data is preserved. Maps (Extras) are merged key-by-key rather than
// replaced. Because an imported QSO has ID==0 and CreatedAt zero, dst's
// identity is naturally preserved. Used by the importer's "update duplicates"
// mode so re-importing an ADIF refreshes QSL/confirmation statuses without
// clobbering fields the source file doesn't carry.
func MergeNonZero(dst *QSO, src QSO) {
dv := reflect.ValueOf(dst).Elem()
sv := reflect.ValueOf(src)
for i := 0; i < dv.NumField(); i++ {
df, sf := dv.Field(i), sv.Field(i)
if !df.CanSet() || sf.IsZero() {
continue
}
if sf.Kind() == reflect.Map {
if df.IsNil() {
df.Set(reflect.MakeMap(sf.Type()))
}
for _, k := range sf.MapKeys() {
df.SetMapIndex(k, sf.MapIndex(k))
}
continue
}
df.Set(sf)
}
}
// QSO represents a contact. Fields are aligned on ADIF naming for
// import/export. Pointers are used to distinguish "absent" from "zero".
// Anything in ADIF that is not a promoted column lands in Extras.
@@ -569,7 +598,7 @@ type WorkedBefore struct {
Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []WorkedEntry `json:"entries"` // up to maxWorkedEntries most recent
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
// --- Per-DXCC entity (populated when DXCC is known) ---
DXCC int `json:"dxcc,omitempty"`
@@ -615,19 +644,6 @@ type BandMode struct {
// WorkedEntry is one prior contact row, lean enough to ship to the UI for
// rendering a recent-contacts mini-list.
type WorkedEntry struct {
ID int64 `json:"id"`
QSODate time.Time `json:"qso_date"`
Band string `json:"band"`
Mode string `json:"mode"`
RSTSent string `json:"rst_sent,omitempty"`
RSTRcvd string `json:"rst_rcvd,omitempty"`
QSLSent string `json:"qsl_sent,omitempty"`
QSLRcvd string `json:"qsl_rcvd,omitempty"`
LOTWSent string `json:"lotw_sent,omitempty"`
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
}
const maxWorkedEntries = 50
// WorkedBefore returns aggregated history at both callsign and DXCC level.
@@ -640,7 +656,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
Bands: []string{},
Modes: []string{},
BandModes: []BandMode{},
Entries: []WorkedEntry{},
Entries: []QSO{},
DXCCBands: []string{},
DXCCModes: []string{},
DXCCBandModes: []BandMode{},
@@ -655,9 +671,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
return wb, fmt.Errorf("count worked: %w", err)
}
if wb.Count > 0 {
rows, err := r.db.QueryContext(ctx, `
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
// Pull the full QSO records (same columns as the Recent QSOs list) so
// the Worked-before grid can offer the same rich column picker.
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM qso WHERE upper(trim(callsign)) = ?
ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries)
@@ -668,40 +684,25 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
modesSet := map[string]struct{}{}
bmSet := map[string]BandMode{}
for rows.Next() {
var (
e WorkedEntry
dateStr string
band, mode sql.NullString
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
)
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
q, err := scanQSO(rows)
if err != nil {
rows.Close()
return wb, fmt.Errorf("scan worked: %w", err)
}
e.QSODate = parseTimeLoose(dateStr)
e.Band = band.String
e.Mode = mode.String
e.RSTSent = rstS.String
e.RSTRcvd = rstR.String
e.QSLSent = qslS.String
e.QSLRcvd = qslR.String
e.LOTWSent = lotwS.String
e.LOTWRcvd = lotwR.String
wb.Entries = append(wb.Entries, e)
if e.Band != "" {
bandsSet[e.Band] = struct{}{}
wb.Entries = append(wb.Entries, q)
if q.Band != "" {
bandsSet[q.Band] = struct{}{}
}
if e.Mode != "" {
modesSet[e.Mode] = struct{}{}
if q.Mode != "" {
modesSet[q.Mode] = struct{}{}
}
if e.Band != "" && e.Mode != "" {
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
if q.Band != "" && q.Mode != "" {
bmSet[q.Band+"|"+q.Mode] = BandMode{Band: q.Band, Mode: q.Mode}
}
if wb.Last.IsZero() {
wb.Last = e.QSODate
wb.Last = q.QSODate
}
wb.First = e.QSODate
wb.First = q.QSODate
}
rows.Close()