diff --git a/app.go b/app.go index d1e4bb8..801b9b8 100644 --- a/app.go +++ b/app.go @@ -1850,13 +1850,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries a.applyQSLDefaults(&q) a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather - // Fill the contacted operator's e-mail from the (cached) lookup so the - // recording can be auto-sent. Cheap: the entry already looked the call up. - if strings.TrimSpace(q.Email) == "" && a.lookup != nil { - if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" { - q.Email = lr.Email - } - } + // NOTE: the contacted operator's e-mail already rides in on the QSO — the entry + // lookup (when you typed the call) fetched it along with name/QTH/grid and the + // form carries it here. There is deliberately NO second lookup at log time: it + // was redundant with the entry lookup and only slowed logging (a call not yet in + // the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished + // when you logged (fast CW: type → Enter), the e-mail is simply blank — fine. id, err = a.qso.Add(a.ctx, q) if err != nil && db.IsConnLost(err) { // The database is UNREACHABLE (not a data error) — park the QSO in the @@ -4528,7 +4527,16 @@ func (a *App) GetQSORate() QSORate { if a.qso == nil { return QSORate{} } - counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute) + // Per-operator on a shared logbook: count only the ACTIVE profile's operator + // so each op sees their own performance, not the cumulative station rate. An + // empty operator (single-op / station owner) matches all their QSOs. + operator := "" + if a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + operator = p.Operator + } + } + counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) if err != nil || len(counts) < 2 { return QSORate{} } @@ -5292,7 +5300,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { if a.lookup == nil { return lookup.Result{}, fmt.Errorf("lookup not initialized") } - r, err := a.lookup.Lookup(a.ctx, callsign) + // Bound the whole lookup: give the providers a couple of seconds, then let + // Lookup fall through to cty.dat (country/zones). Without this a call that isn't + // in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner + // turning for 10 s+ before the cty.dat fallback showed. The providers respect + // the context, so they're cancelled at the deadline and cty.dat answers instantly. + ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second) + defer cancel() + r, err := a.lookup.Lookup(ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { return lookup.Result{}, fmt.Errorf("callsign not found") } @@ -5822,8 +5837,10 @@ func (a *App) saveQSORecording(q *qso.QSO) { if q.Extras == nil { q.Extras = map[string]string{} } - q.Extras["APP_OPSLOG_RECORDING"] = name - if err := a.qso.Update(a.ctx, *q); err != nil { + q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine + // Persist ONLY this extras key (targeted) — a full-row Update from this + // in-memory copy could revert a column a concurrent post-log action changed. + if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil { applog.Printf("qso-rec: store recording path: %v", err) } } @@ -6695,17 +6712,10 @@ func (a *App) markRecordingSent(id int64) { if a.qso == nil || id == 0 { return } - q, err := a.qso.GetByID(a.ctx, id) - if err != nil { - applog.Printf("qso-rec: mark sent: load %d: %v", id, err) - return - } - if q.Extras == nil { - q.Extras = map[string]string{} - } - q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02") - if err := a.qso.Update(a.ctx, q); err != nil { - applog.Printf("qso-rec: mark sent: update %d: %v", id, err) + // Targeted extras write — never a full-row Update, which (from a stale copy) + // could revert a clublog/qrz upload-status another action just stamped. + if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil { + applog.Printf("qso-rec: mark sent %d: %v", id, err) } } diff --git a/app_qsl_designer.go b/app_qsl_designer.go index 478dfb8..bf31838 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error { applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err) return err } - // Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — - // NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay - // independent. q came straight from GetByID, so a full Update rewrites the - // row unchanged apart from this field. - if q.Extras == nil { - q.Extras = map[string]string{} - } - q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339) - if err := a.qso.Update(a.ctx, q); err != nil { - applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err) - return fmt.Errorf("eQSL sent but status not saved: %w", err) + // Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT + // the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent. + // + // Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q` + // read up top is now stale after the slow e-mail send, and rewriting the whole + // row would revert any column an auto-upload changed meanwhile — that's how + // sending a QSL card was flipping clublog_qso_upload_status back from Y to R. + if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil { + applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err) + return fmt.Errorf("QSL card sent but status not saved: %w", err) } applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign) wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID) diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 8023a04..0c8936d 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri return n, nil } +// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of +// the row. A slow caller that only wants to stamp its own app field (e-mailing a +// QSL card, saving a recording) must not do a full-row Update: the row it read may +// be seconds stale, and writing it all back silently reverts any column another +// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status +// from R to Y. Touching only `extras` makes that impossible. Empty value deletes +// the key. +func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error { + if id == 0 || strings.TrimSpace(key) == "" { + return fmt.Errorf("missing id or key") + } + var extrasJSON sql.NullString + if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil { + return fmt.Errorf("load extras: %w", err) + } + m := decodeExtras(extrasJSON.String) + if m == nil { + m = map[string]string{} + } + if value == "" { + delete(m, key) + } else { + m[key] = value + } + if _, err := r.db.ExecContext(ctx, + `UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`, + encodeExtras(m), db.NowISO(), id); err != nil { + return fmt.Errorf("set extra %s: %w", key, err) + } + return nil +} + // 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 { @@ -1864,25 +1896,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) { } // RecentRate counts QSOs whose start time falls within each trailing window from -// `now` — the live "QSO rate" meter shown in the header. It scans only the most -// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour -// was inserted recently; that keeps it cheap even on a large log. qso_date is the -// repo's text column, parsed with parseTimeLoose (backend-format agnostic). -func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) { +// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty +// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op +// sees their OWN performance, not the cumulative rate; empty operator matches every +// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since +// any QSO in the last hour was inserted recently; that keeps it cheap even on a large +// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format +// agnostic). +func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) { counts := make([]int, len(windows)) - // 400 rows covers a full hour even at a blistering contest rate (>300/h); any - // QSO inside the trailing windows is among the most recently inserted. - rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`) + // 2000 rows covers a full hour for one operator even in a busy multi-op run + // (other operators' rows are discarded before counting). + rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) if err != nil { return counts, err } defer rows.Close() now = now.UTC() + opFilter := strings.ToUpper(strings.TrimSpace(operator)) for rows.Next() { - var dateStr sql.NullString - if err := rows.Scan(&dateStr); err != nil { + var oper, dateStr sql.NullString + if err := rows.Scan(&oper, &dateStr); err != nil { return counts, err } + if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter { + continue // a different operator's QSO — not part of my rate + } t := parseTimeLoose(dateStr.String).UTC() if t.IsZero() || t.After(now) { continue