// Package qso models a radio contact (QSO) and provides its repository. package qso import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" ) // 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. type QSO struct { ID int64 `json:"id"` Callsign string `json:"callsign"` QSODate time.Time `json:"qso_date"` // start, UTC QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC Band string `json:"band"` BandRX string `json:"band_rx,omitempty"` Mode string `json:"mode"` Submode string `json:"submode,omitempty"` FreqHz *int64 `json:"freq_hz,omitempty"` FreqRXHz *int64 `json:"freq_rx_hz,omitempty"` RSTSent string `json:"rst_sent,omitempty"` RSTRcvd string `json:"rst_rcvd,omitempty"` // --- Contacted station --- Name string `json:"name,omitempty"` QTH string `json:"qth,omitempty"` Address string `json:"address,omitempty"` Email string `json:"email,omitempty"` Web string `json:"web,omitempty"` Grid string `json:"grid,omitempty"` GridExt string `json:"gridsquare_ext,omitempty"` VUCCGrids string `json:"vucc_grids,omitempty"` Country string `json:"country,omitempty"` State string `json:"state,omitempty"` County string `json:"cnty,omitempty"` DXCC *int `json:"dxcc,omitempty"` Continent string `json:"cont,omitempty"` CQZ *int `json:"cqz,omitempty"` ITUZ *int `json:"ituz,omitempty"` IOTA string `json:"iota,omitempty"` SOTARef string `json:"sota_ref,omitempty"` POTARef string `json:"pota_ref,omitempty"` Age *int `json:"age,omitempty"` Lat *float64 `json:"lat,omitempty"` Lon *float64 `json:"lon,omitempty"` Rig string `json:"rig,omitempty"` Ant string `json:"ant,omitempty"` // --- QSL / LoTW / eQSL / Clublog / HRDLog --- QSLSent string `json:"qsl_sent,omitempty"` QSLRcvd string `json:"qsl_rcvd,omitempty"` QSLSentDate string `json:"qsl_sent_date,omitempty"` QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"` QSLVia string `json:"qsl_via,omitempty"` QSLMsg string `json:"qsl_msg,omitempty"` QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"` LOTWSent string `json:"lotw_sent,omitempty"` LOTWRcvd string `json:"lotw_rcvd,omitempty"` LOTWSentDate string `json:"lotw_sent_date,omitempty"` LOTWRcvdDate string `json:"lotw_rcvd_date,omitempty"` EQSLSent string `json:"eqsl_sent,omitempty"` EQSLRcvd string `json:"eqsl_rcvd,omitempty"` EQSLSentDate string `json:"eqsl_sent_date,omitempty"` EQSLRcvdDate string `json:"eqsl_rcvd_date,omitempty"` ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"` ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"` HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"` HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"` // --- Contest --- ContestID string `json:"contest_id,omitempty"` SRX *int `json:"srx,omitempty"` STX *int `json:"stx,omitempty"` SRXString string `json:"srx_string,omitempty"` STXString string `json:"stx_string,omitempty"` Check string `json:"check,omitempty"` Precedence string `json:"precedence,omitempty"` ARRLSect string `json:"arrl_sect,omitempty"` // --- Satellite / propagation --- PropMode string `json:"prop_mode,omitempty"` SatName string `json:"sat_name,omitempty"` SatMode string `json:"sat_mode,omitempty"` AntAz *float64 `json:"ant_az,omitempty"` AntEl *float64 `json:"ant_el,omitempty"` AntPath string `json:"ant_path,omitempty"` // --- My station / operator --- StationCallsign string `json:"station_callsign,omitempty"` Operator string `json:"operator,omitempty"` MyGrid string `json:"my_grid,omitempty"` MyGridExt string `json:"my_gridsquare_ext,omitempty"` MyCountry string `json:"my_country,omitempty"` MyState string `json:"my_state,omitempty"` MyCounty string `json:"my_cnty,omitempty"` MyIOTA string `json:"my_iota,omitempty"` MySOTARef string `json:"my_sota_ref,omitempty"` MyPOTARef string `json:"my_pota_ref,omitempty"` MyDXCC *int `json:"my_dxcc,omitempty"` MyCQZone *int `json:"my_cq_zone,omitempty"` MyITUZone *int `json:"my_itu_zone,omitempty"` MyLat *float64 `json:"my_lat,omitempty"` MyLon *float64 `json:"my_lon,omitempty"` MyStreet string `json:"my_street,omitempty"` MyCity string `json:"my_city,omitempty"` MyPostalCode string `json:"my_postal_code,omitempty"` MyRig string `json:"my_rig,omitempty"` MyAntenna string `json:"my_antenna,omitempty"` // --- Misc --- TXPower *float64 `json:"tx_pwr,omitempty"` Comment string `json:"comment,omitempty"` Notes string `json:"notes,omitempty"` // Extras holds ADIF fields not promoted to columns. Keys are uppercase // ADIF field names (e.g. "DARC_DOK"); values are the raw string content. Extras map[string]string `json:"extras,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // ListFilter filters a search in the logbook. // Any zero/nil field is ignored. type ListFilter struct { Callsign string `json:"callsign,omitempty"` Band string `json:"band,omitempty"` Mode string `json:"mode,omitempty"` StationCallsign string `json:"station_callsign,omitempty"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } // Repo accesses the qso table. type Repo struct { db *sql.DB } // NewRepo builds a QSO repo on the given connection. func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } const isoMillis = "2006-01-02T15:04:05.000Z" // columnList is the canonical list of columns in declaration order. It is // used by both read and write queries so they stay in sync. const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submode, freq_hz, freq_rx_hz, rst_sent, rst_rcvd, name, qth, address, email, web, grid, gridsquare_ext, vucc_grids, country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref, age, lat, lon, rig, ant, qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd, lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date, eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date, clublog_qso_upload_date, clublog_qso_upload_status, hrdlog_qso_upload_date, hrdlog_qso_upload_status, contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect, prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path, station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota, my_sota_ref, my_pota_ref, my_dxcc, my_cq_zone, my_itu_zone, my_lat, my_lon, my_street, my_city, my_postal_code, my_rig, my_antenna, tx_pwr, comment, notes, extras_json` const selectCols = `id, ` + columnList + `, created_at, updated_at` // columnCount is derived from columnList at init so they can never drift. var columnCount = countColumns(columnList) // insertPlaceholders returns "?,?,?,..." matching columnCount. var insertPlaceholders = buildInsertPlaceholders() func countColumns(s string) int { n := 1 for i := 0; i < len(s); i++ { if s[i] == ',' { n++ } } return n } func buildInsertPlaceholders() string { out := make([]byte, 0, 2*columnCount) for i := 0; i < columnCount; i++ { if i > 0 { out = append(out, ',') } out = append(out, '?') } return string(out) } // args returns the QSO field values in the same order as columnList. func (q *QSO) args() []any { extras := encodeExtras(q.Extras) return []any{ q.Callsign, q.QSODate.UTC().Format(isoMillis), nullDateTime(q.QSODateOff), q.Band, q.BandRX, q.Mode, q.Submode, q.FreqHz, q.FreqRXHz, q.RSTSent, q.RSTRcvd, q.Name, q.QTH, q.Address, q.Email, q.Web, q.Grid, q.GridExt, q.VUCCGrids, q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef, q.Age, q.Lat, q.Lon, q.Rig, q.Ant, q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd, q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate, q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate, q.ClublogUploadDate, q.ClublogUploadStatus, q.HRDLogUploadDate, q.HRDLogUploadStatus, q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect, q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath, q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA, q.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon, q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna, q.TXPower, q.Comment, q.Notes, extras, } } // nullDateTime renders a time.Time as ISO UTC or nil if zero. func nullDateTime(t time.Time) any { if t.IsZero() { return nil } return t.UTC().Format(isoMillis) } // encodeExtras returns a JSON-encoded string or nil if empty. func encodeExtras(m map[string]string) any { if len(m) == 0 { return nil } b, err := json.Marshal(m) if err != nil { return nil } return string(b) } func decodeExtras(s string) map[string]string { if s == "" { return nil } var m map[string]string if err := json.Unmarshal([]byte(s), &m); err != nil { return nil } return m } // Add inserts a QSO and returns its ID. func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) { if q.Callsign == "" { return 0, fmt.Errorf("empty callsign") } if q.QSODate.IsZero() { q.QSODate = time.Now().UTC() } res, err := r.db.ExecContext(ctx, `INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`, q.args()...) if err != nil { return 0, fmt.Errorf("insert qso: %w", err) } return res.LastInsertId() } // AddBatch inserts many QSOs inside a single transaction using a prepared // statement. Empty-callsign records are skipped. Returns rows inserted. func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) { if len(qsos) == 0 { return 0, nil } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, `INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`) if err != nil { return 0, fmt.Errorf("prepare batch insert: %w", err) } defer stmt.Close() var inserted int64 for _, q := range qsos { if q.Callsign == "" { continue } if q.QSODate.IsZero() { q.QSODate = time.Now().UTC() } if _, err := stmt.ExecContext(ctx, q.args()...); err != nil { return inserted, fmt.Errorf("insert qso %q: %w", q.Callsign, err) } inserted++ } if err := tx.Commit(); err != nil { return 0, fmt.Errorf("commit batch: %w", err) } return inserted, nil } // GetByID fetches a single QSO by primary key. func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) { row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+` FROM qso WHERE id = ?`, id) return scanQSO(row) } // Update overwrites all editable fields of an existing QSO. updated_at is bumped. func (r *Repo) Update(ctx context.Context, q QSO) error { if q.ID == 0 { return fmt.Errorf("missing id") } if q.Callsign == "" { return fmt.Errorf("empty callsign") } if q.QSODate.IsZero() { q.QSODate = time.Now().UTC() } setClause := buildUpdateSetClause() args := append(q.args(), q.ID) res, err := r.db.ExecContext(ctx, `UPDATE qso SET `+setClause+`, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, args...) if err != nil { return fmt.Errorf("update qso %d: %w", q.ID, err) } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // buildUpdateSetClause turns "col1, col2, …" into "col1 = ?, col2 = ?, …". func buildUpdateSetClause() string { // Quick split — columnList has no escaped commas. var out []byte col := []byte{} flush := func() { if len(col) == 0 { return } if len(out) > 0 { out = append(out, ',', ' ') } // trim leading whitespace i := 0 for i < len(col) && (col[i] == ' ' || col[i] == '\n' || col[i] == '\t') { i++ } out = append(out, col[i:]...) out = append(out, ' ', '=', ' ', '?') col = col[:0] } for i := 0; i < len(columnList); i++ { c := columnList[i] if c == ',' { flush() continue } col = append(col, c) } flush() return string(out) } // DeleteAll wipes every QSO from the logbook. Returns the number of rows // removed. Use with care — there is no recovery short of a DB backup. // Runs in a transaction and VACUUMs afterwards so the file actually shrinks. func (r *Repo) DeleteAll(ctx context.Context) (int64, error) { res, err := r.db.ExecContext(ctx, `DELETE FROM qso`) if err != nil { return 0, fmt.Errorf("delete all qso: %w", err) } n, _ := res.RowsAffected() // VACUUM is run outside any transaction; reclaims pages so the .db file // stops being a 200 MB ghost after a 25k-row wipe. if _, err := r.db.ExecContext(ctx, `VACUUM`); err != nil { // Non-fatal: rows are gone, just file isn't shrunk. Surface the err. return n, fmt.Errorf("delete ok (%d rows) but vacuum failed: %w", n, err) } return n, nil } // Delete removes a QSO by id. func (r *Repo) Delete(ctx context.Context, id int64) error { res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete qso %d: %w", id, err) } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // List returns QSOs matching the filter, sorted by date desc. func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) { q := `SELECT ` + selectCols + ` FROM qso WHERE 1=1` args := []any{} if f.Callsign != "" { q += " AND callsign LIKE ?" args = append(args, f.Callsign+"%") } if f.Band != "" { q += " AND band = ?" args = append(args, f.Band) } if f.Mode != "" { q += " AND mode = ?" args = append(args, f.Mode) } if f.StationCallsign != "" { q += " AND station_callsign = ?" args = append(args, f.StationCallsign) } q += " ORDER BY qso_date DESC, id DESC" if f.Limit <= 0 || f.Limit > 1000 { f.Limit = 200 } q += " LIMIT ? OFFSET ?" args = append(args, f.Limit, f.Offset) rows, err := r.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("query qso: %w", err) } defer rows.Close() out := make([]QSO, 0, 64) for rows.Next() { q, err := scanQSO(rows) if err != nil { return nil, err } out = append(out, q) } return out, rows.Err() } // WorkedBefore summarises prior contacts at two granularities: // - by exact callsign → shown as "this call worked N×" // - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT // // In ham radio the meaningful "new one" is a new DXCC entity, not a brand-new // callsign — most QSOs to known countries aren't worth flagging. type WorkedBefore struct { Callsign string `json:"callsign"` // --- Per-callsign --- Count int `json:"count"` // total prior QSOs with this call First time.Time `json:"first,omitempty"` // oldest call QSO date Last time.Time `json:"last,omitempty"` // most recent call QSO date 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 // --- Per-DXCC entity (populated when DXCC is known) --- DXCC int `json:"dxcc,omitempty"` DXCCName string `json:"dxcc_name,omitempty"` // human country name (best effort) DXCCCount int `json:"dxcc_count"` DXCCFirst time.Time `json:"dxcc_first,omitempty"` DXCCLast time.Time `json:"dxcc_last,omitempty"` DXCCBands []string `json:"dxcc_bands"` DXCCModes []string `json:"dxcc_modes"` DXCCBandModes []BandMode `json:"dxcc_band_modes"` // Status grid driving the band×class matrix in the UI. One entry per // (band, class) where ANY QSO exists in this DXCC. Only the highest // status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w). BandStatus []BandStatus `json:"band_status"` } // BandStatus is one cell in the worked-before grid. type BandStatus struct { Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Class string `json:"class"` // "PH" | "CW" | "DIG" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" } // modeClass collapses ADIF modes into the three buckets DXers care about. // Anything not voice and not CW is treated as digital. func modeClass(mode string) string { switch strings.ToUpper(mode) { case "SSB", "USB", "LSB", "AM", "FM", "DIGITALVOICE", "PHONE": return "PH" case "CW": return "CW" default: return "DIG" } } // BandMode is a (band, mode) pair used for the NEW SLOT check. type BandMode struct { Band string `json:"band"` Mode string `json:"mode"` } // 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. // dxccHint lets the caller pass a known DXCC number (e.g. from a fresh QRZ // lookup) when the call has never been worked. If 0, the DXCC is inferred // from the most recent prior QSO with the same callsign. func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int) (WorkedBefore, error) { wb := WorkedBefore{ Callsign: upperTrim(callsign), Bands: []string{}, Modes: []string{}, BandModes: []BandMode{}, Entries: []WorkedEntry{}, DXCCBands: []string{}, DXCCModes: []string{}, DXCCBandModes: []BandMode{}, } if wb.Callsign == "" { return wb, nil } // ---- Per-callsign stats ---- if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil { 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 FROM qso WHERE callsign = ? ORDER BY qso_date DESC, id DESC LIMIT ?`, wb.Callsign, maxWorkedEntries) if err != nil { return wb, fmt.Errorf("query worked: %w", err) } bandsSet := map[string]struct{}{} modesSet := map[string]struct{}{} bmSet := map[string]BandMode{} for rows.Next() { var ( e WorkedEntry dateStr string rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString ) if err := rows.Scan(&e.ID, &dateStr, &e.Band, &e.Mode, &rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil { rows.Close() return wb, fmt.Errorf("scan worked: %w", err) } e.QSODate = parseTimeLoose(dateStr) 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) bandsSet[e.Band] = struct{}{} modesSet[e.Mode] = struct{}{} bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode} if wb.Last.IsZero() { wb.Last = e.QSODate } wb.First = e.QSODate } rows.Close() if wb.Count > maxWorkedEntries { var firstStr sql.NullString _ = r.db.QueryRowContext(ctx, `SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr) if firstStr.Valid { wb.First = parseTimeLoose(firstStr.String) } } for b := range bandsSet { wb.Bands = append(wb.Bands, b) } for m := range modesSet { wb.Modes = append(wb.Modes, m) } for _, bm := range bmSet { wb.BandModes = append(wb.BandModes, bm) } sortStrings(wb.Bands) sortStrings(wb.Modes) } // ---- Resolve DXCC ---- dxcc := dxccHint if dxcc == 0 && wb.Count > 0 { // Take the most recent non-null DXCC from past QSOs with this call. var d sql.NullInt64 _ = r.db.QueryRowContext(ctx, ` SELECT dxcc FROM qso WHERE callsign = ? AND dxcc IS NOT NULL ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d) if d.Valid { dxcc = int(d.Int64) } } if dxcc <= 0 { return wb, nil } wb.DXCC = dxcc // Best-effort country name: latest non-null country for this DXCC. var name sql.NullString _ = r.db.QueryRowContext(ctx, ` SELECT country FROM qso WHERE dxcc = ? AND country IS NOT NULL AND country != '' ORDER BY qso_date DESC LIMIT 1`, dxcc).Scan(&name) wb.DXCCName = name.String // ---- Per-DXCC stats ---- if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), MIN(qso_date), MAX(qso_date) FROM qso WHERE dxcc = ?`, dxcc).Scan( &wb.DXCCCount, &nullableTimeScan{&wb.DXCCFirst}, &nullableTimeScan{&wb.DXCCLast}, ); err != nil { return wb, fmt.Errorf("count dxcc: %w", err) } if wb.DXCCCount == 0 { return wb, nil } if err := r.collectDistinct(ctx, &wb.DXCCBands, `SELECT DISTINCT band FROM qso WHERE dxcc = ?`, dxcc); err != nil { return wb, err } if err := r.collectDistinct(ctx, &wb.DXCCModes, `SELECT DISTINCT mode FROM qso WHERE dxcc = ?`, dxcc); err != nil { return wb, err } bmRows, err := r.db.QueryContext(ctx, `SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?`, dxcc) if err != nil { return wb, fmt.Errorf("dxcc band_modes: %w", err) } for bmRows.Next() { var b, m string if err := bmRows.Scan(&b, &m); err != nil { bmRows.Close() return wb, err } wb.DXCCBandModes = append(wb.DXCCBandModes, BandMode{Band: b, Mode: m}) } bmRows.Close() sortStrings(wb.DXCCBands) sortStrings(wb.DXCCModes) // ---- Per-(band, class) status grid ---- // One pass over every distinct (band, mode) in the DXCC, aggregating // "did this call work it?" and "was anything confirmed?" via MAX. // Status precedence: call_c > call_w > dxcc_c > dxcc_w. statusRows, err := r.db.QueryContext(ctx, ` SELECT band, mode, MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END), MAX(CASE WHEN callsign = ? AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y') THEN 1 ELSE 0 END), MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y' THEN 1 ELSE 0 END) FROM qso WHERE dxcc = ? GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc) if err != nil { return wb, fmt.Errorf("band status: %w", err) } type cellKey struct{ band, class string } const ( stDxccW = 0 stDxccC = 1 stCallW = 2 stCallC = 3 ) best := map[cellKey]int{} for statusRows.Next() { var band, mode string var callW, callC, dxccConfirmed int if err := statusRows.Scan(&band, &mode, &callW, &callC, &dxccConfirmed); err != nil { statusRows.Close() return wb, fmt.Errorf("scan band status: %w", err) } code := stDxccW // row exists ⇒ entity worked at minimum if dxccConfirmed == 1 { code = stDxccC } if callW == 1 { code = stCallW } if callC == 1 { code = stCallC } k := cellKey{band: band, class: modeClass(mode)} if cur, ok := best[k]; !ok || code > cur { best[k] = code } } statusRows.Close() codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"} for k, code := range best { wb.BandStatus = append(wb.BandStatus, BandStatus{ Band: k.band, Class: k.class, Status: codeStr[code], }) } return wb, nil } // collectDistinct fills *out with the first column of every row. func (r *Repo) collectDistinct(ctx context.Context, out *[]string, query string, args ...any) error { rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return fmt.Errorf("distinct: %w", err) } defer rows.Close() for rows.Next() { var s string if err := rows.Scan(&s); err != nil { return err } *out = append(*out, s) } return rows.Err() } // nullableTimeScan adapts a *time.Time to sql.Scanner for fields that may // be NULL in aggregate queries (MIN/MAX over an empty subset returns NULL). type nullableTimeScan struct { dst *time.Time } func (n *nullableTimeScan) Scan(src any) error { if src == nil { return nil } switch v := src.(type) { case string: *n.dst = parseTimeLoose(v) case []byte: *n.dst = parseTimeLoose(string(v)) } return nil } func upperTrim(s string) string { out := []byte(s) // strings.TrimSpace + ToUpper without importing strings for this micro-helper. start, end := 0, len(out) for start < end && out[start] <= ' ' { start++ } for end > start && out[end-1] <= ' ' { end-- } for i := start; i < end; i++ { if out[i] >= 'a' && out[i] <= 'z' { out[i] -= 32 } } return string(out[start:end]) } func sortStrings(s []string) { // Tiny insertion sort: typical worked-before slices are < 20 items. for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j-1] > s[j]; j-- { s[j-1], s[j] = s[j], s[j-1] } } } // Count returns the total number of QSOs in the database. func (r *Repo) Count(ctx context.Context) (int64, error) { var n int64 err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso`).Scan(&n) return n, err } // scanner is what both *sql.Row and *sql.Rows satisfy for our needs. type scanner interface { Scan(dest ...any) error } // scanQSO reads one row produced by selectCols into a QSO. // Long but flat — kept in field declaration order to make audits easy. func scanQSO(s scanner) (QSO, error) { var q QSO var ( qsoDateStr string qsoDateOffStr sql.NullString bandRx, submode sql.NullString freqHz, freqRX sql.NullInt64 rstS, rstR sql.NullString name, qth, addr, email, web sql.NullString grid, gridExt, vucc sql.NullString country, state, cnty sql.NullString dxcc, cqz, ituz sql.NullInt64 cont, iota, sota, pota sql.NullString age sql.NullInt64 lat, lon sql.NullFloat64 rig, ant sql.NullString qslSent, qslRcvd sql.NullString qslSentDate, qslRcvdDate sql.NullString qslVia, qslMsg, qslMsgRcvd sql.NullString lotwSent, lotwRcvd sql.NullString lotwSentDate, lotwRcvdDate sql.NullString eqslSent, eqslRcvd sql.NullString eqslSentDate, eqslRcvdDate sql.NullString clublogDate, clublogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString contestID sql.NullString srx, stx sql.NullInt64 srxStr, stxStr sql.NullString checkField, precedence, arrlSect sql.NullString propMode, satName, satMode sql.NullString antAz, antEl sql.NullFloat64 antPath sql.NullString stCall, op, myGrid, myGridExt sql.NullString myCountry, myState, myCnty, myIOTA sql.NullString mySOTA, myPOTA sql.NullString myDXCC, myCQZ, myITUZ sql.NullInt64 myLat, myLon sql.NullFloat64 myStreet, myCity, myPostal sql.NullString myRig, myAntenna sql.NullString txp sql.NullFloat64 comment, notes sql.NullString extrasJSON sql.NullString createdStr, updatedStr string ) if err := s.Scan( &q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX, &rstS, &rstR, &name, &qth, &addr, &email, &web, &grid, &gridExt, &vucc, &country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota, &age, &lat, &lon, &rig, &ant, &qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd, &lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate, &eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate, &clublogDate, &clublogStatus, &hrdlogDate, &hrdlogStatus, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &propMode, &satName, &satMode, &antAz, &antEl, &antPath, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &myStreet, &myCity, &myPostal, &myRig, &myAntenna, &txp, &comment, ¬es, &extrasJSON, &createdStr, &updatedStr, ); err != nil { return QSO{}, fmt.Errorf("scan qso: %w", err) } q.QSODate = parseTimeLoose(qsoDateStr) q.QSODateOff = parseTimeLoose(qsoDateOffStr.String) q.CreatedAt = parseTimeLoose(createdStr) q.UpdatedAt = parseTimeLoose(updatedStr) q.BandRX = bandRx.String q.Submode = submode.String if freqHz.Valid { v := freqHz.Int64 q.FreqHz = &v } if freqRX.Valid { v := freqRX.Int64 q.FreqRXHz = &v } q.RSTSent = rstS.String q.RSTRcvd = rstR.String q.Name = name.String q.QTH = qth.String q.Address = addr.String q.Email = email.String q.Web = web.String q.Grid = grid.String q.GridExt = gridExt.String q.VUCCGrids = vucc.String q.Country = country.String q.State = state.String q.County = cnty.String if dxcc.Valid { v := int(dxcc.Int64) q.DXCC = &v } q.Continent = cont.String if cqz.Valid { v := int(cqz.Int64) q.CQZ = &v } if ituz.Valid { v := int(ituz.Int64) q.ITUZ = &v } q.IOTA = iota.String q.SOTARef = sota.String q.POTARef = pota.String if age.Valid { v := int(age.Int64) q.Age = &v } if lat.Valid { v := lat.Float64 q.Lat = &v } if lon.Valid { v := lon.Float64 q.Lon = &v } q.Rig = rig.String q.Ant = ant.String q.QSLSent = qslSent.String q.QSLRcvd = qslRcvd.String q.QSLSentDate = qslSentDate.String q.QSLRcvdDate = qslRcvdDate.String q.QSLVia = qslVia.String q.QSLMsg = qslMsg.String q.QSLMsgRcvd = qslMsgRcvd.String q.LOTWSent = lotwSent.String q.LOTWRcvd = lotwRcvd.String q.LOTWSentDate = lotwSentDate.String q.LOTWRcvdDate = lotwRcvdDate.String q.EQSLSent = eqslSent.String q.EQSLRcvd = eqslRcvd.String q.EQSLSentDate = eqslSentDate.String q.EQSLRcvdDate = eqslRcvdDate.String q.ClublogUploadDate = clublogDate.String q.ClublogUploadStatus = clublogStatus.String q.HRDLogUploadDate = hrdlogDate.String q.HRDLogUploadStatus = hrdlogStatus.String q.ContestID = contestID.String if srx.Valid { v := int(srx.Int64) q.SRX = &v } if stx.Valid { v := int(stx.Int64) q.STX = &v } q.SRXString = srxStr.String q.STXString = stxStr.String q.Check = checkField.String q.Precedence = precedence.String q.ARRLSect = arrlSect.String q.PropMode = propMode.String q.SatName = satName.String q.SatMode = satMode.String if antAz.Valid { v := antAz.Float64 q.AntAz = &v } if antEl.Valid { v := antEl.Float64 q.AntEl = &v } q.AntPath = antPath.String q.StationCallsign = stCall.String q.Operator = op.String q.MyGrid = myGrid.String q.MyGridExt = myGridExt.String q.MyCountry = myCountry.String q.MyState = myState.String q.MyCounty = myCnty.String q.MyIOTA = myIOTA.String q.MySOTARef = mySOTA.String q.MyPOTARef = myPOTA.String if myDXCC.Valid { v := int(myDXCC.Int64) q.MyDXCC = &v } if myCQZ.Valid { v := int(myCQZ.Int64) q.MyCQZone = &v } if myITUZ.Valid { v := int(myITUZ.Int64) q.MyITUZone = &v } if myLat.Valid { v := myLat.Float64 q.MyLat = &v } if myLon.Valid { v := myLon.Float64 q.MyLon = &v } q.MyStreet = myStreet.String q.MyCity = myCity.String q.MyPostalCode = myPostal.String q.MyRig = myRig.String q.MyAntenna = myAntenna.String if txp.Valid { v := txp.Float64 q.TXPower = &v } q.Comment = comment.String q.Notes = notes.String q.Extras = decodeExtras(extrasJSON.String) return q, nil } // parseTimeLoose tries several common ISO layouts returned by SQLite. func parseTimeLoose(s string) time.Time { if s == "" { return time.Time{} } for _, layout := range []string{isoMillis, "2006-01-02T15:04:05Z", "2006-01-02 15:04:05"} { if t, err := time.Parse(layout, s); err == nil { return t.UTC() } } return time.Time{} }