feat: Adding French language
This commit is contained in:
@@ -3775,6 +3775,170 @@ func (a *App) ListContests() []contest.Def {
|
||||
return contest.List()
|
||||
}
|
||||
|
||||
// ContestBandRow is one band's QSO count in the contest scoreboard.
|
||||
type ContestBandRow struct {
|
||||
Band string `json:"band"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ContestStatsResult is the live contest scoreboard. Score is a best-effort
|
||||
// ESTIMATE (qsos × mult): real contest scoring is per-contest and rule-heavy, so
|
||||
// this is a working indicator, not an official claim.
|
||||
type ContestStatsResult struct {
|
||||
QSOs int `json:"qsos"`
|
||||
ByBand []ContestBandRow `json:"by_band"`
|
||||
Mult int `json:"mult"`
|
||||
MultLabel string `json:"mult_label"`
|
||||
Score int `json:"score"`
|
||||
LastHour int `json:"last_hour"` // QSOs in the last 60 min (rate)
|
||||
}
|
||||
|
||||
// parseTimeISO parses an RFC3339(/nano) timestamp; ok=false for an empty/invalid
|
||||
// value (meaning "no bound").
|
||||
func parseTimeISO(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ContestStats computes the scoreboard for the given CONTEST_ID within the
|
||||
// [startISO, endISO] window (either bound may be empty = open). exchange is the
|
||||
// active exchange kind, which decides what counts as a multiplier.
|
||||
func (a *App) ContestStats(contestID, exchange, startISO, endISO string) ContestStatsResult {
|
||||
res := ContestStatsResult{}
|
||||
if a.qso == nil || strings.TrimSpace(contestID) == "" {
|
||||
return res
|
||||
}
|
||||
start, hasStart := parseTimeISO(startISO)
|
||||
end, hasEnd := parseTimeISO(endISO)
|
||||
f := qso.QueryFilter{
|
||||
Conditions: []qso.Condition{{Field: "contest_id", Op: "eq", Value: contestID}},
|
||||
Match: "AND", Limit: 1_000_000,
|
||||
}
|
||||
bandCount := map[string]int{}
|
||||
multSet := map[string]struct{}{}
|
||||
label := "DXCC"
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
|
||||
if hasStart && q.QSODate.Before(start) {
|
||||
return nil
|
||||
}
|
||||
if hasEnd && q.QSODate.After(end) {
|
||||
return nil
|
||||
}
|
||||
res.QSOs++
|
||||
if q.Band != "" {
|
||||
bandCount[q.Band]++
|
||||
}
|
||||
if !q.QSODate.IsZero() && q.QSODate.After(cutoff) {
|
||||
res.LastHour++
|
||||
}
|
||||
switch exchange {
|
||||
case "cq-zone":
|
||||
label = "Zones"
|
||||
if q.CQZ != nil && *q.CQZ > 0 {
|
||||
multSet[fmt.Sprintf("Z%d", *q.CQZ)] = struct{}{}
|
||||
}
|
||||
case "itu-zone":
|
||||
label = "Zones"
|
||||
if q.ITUZ != nil && *q.ITUZ > 0 {
|
||||
multSet[fmt.Sprintf("I%d", *q.ITUZ)] = struct{}{}
|
||||
}
|
||||
case "dept", "state", "name", "grid", "age", "section":
|
||||
label = "Mults"
|
||||
k := strings.ToUpper(strings.TrimSpace(q.SRXString))
|
||||
if k == "" && q.SRX != nil {
|
||||
k = fmt.Sprintf("%d", *q.SRX)
|
||||
}
|
||||
if k != "" {
|
||||
multSet[k] = struct{}{}
|
||||
}
|
||||
default: // serial and others
|
||||
if strings.Contains(strings.ToUpper(contestID), "WPX") {
|
||||
label = "Prefixes"
|
||||
if p := award.WPXPrefix(q.Callsign); p != "" {
|
||||
multSet[p] = struct{}{}
|
||||
}
|
||||
} else {
|
||||
label = "DXCC"
|
||||
if q.DXCC != nil && *q.DXCC > 0 {
|
||||
multSet[fmt.Sprintf("%d", *q.DXCC)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
res.Mult = len(multSet)
|
||||
res.MultLabel = label
|
||||
if res.Mult > 0 {
|
||||
res.Score = res.QSOs * res.Mult
|
||||
} else {
|
||||
res.Score = res.QSOs
|
||||
}
|
||||
for b, c := range bandCount {
|
||||
res.ByBand = append(res.ByBand, ContestBandRow{Band: b, Count: c})
|
||||
}
|
||||
if res.ByBand == nil {
|
||||
res.ByBand = []ContestBandRow{} // never nil → marshals as [] not null (frontend guard)
|
||||
}
|
||||
sort.Slice(res.ByBand, func(i, j int) bool { return res.ByBand[i].Band < res.ByBand[j].Band })
|
||||
return res
|
||||
}
|
||||
|
||||
// ContestDupe reports whether call was already worked in this contest on the
|
||||
// same band + mode class since startISO (the usual dupe rule). Empty band/mode/
|
||||
// startISO widens the check.
|
||||
func (a *App) ContestDupe(call, contestID, band, mode, startISO string) bool {
|
||||
if a.qso == nil || strings.TrimSpace(call) == "" || strings.TrimSpace(contestID) == "" {
|
||||
return false
|
||||
}
|
||||
start, hasStart := parseTimeISO(startISO)
|
||||
f := qso.QueryFilter{
|
||||
Conditions: []qso.Condition{
|
||||
{Field: "contest_id", Op: "eq", Value: contestID},
|
||||
{Field: "callsign", Op: "eq", Value: strings.ToUpper(strings.TrimSpace(call))},
|
||||
},
|
||||
Match: "AND", Limit: 1000,
|
||||
}
|
||||
if strings.TrimSpace(band) != "" {
|
||||
f.Conditions = append(f.Conditions, qso.Condition{Field: "band", Op: "eq", Value: band})
|
||||
}
|
||||
want := contestModeClass(mode)
|
||||
dupe := false
|
||||
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
|
||||
if hasStart && q.QSODate.Before(start) {
|
||||
return nil
|
||||
}
|
||||
if want == "" || contestModeClass(q.Mode) == want {
|
||||
dupe = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return dupe
|
||||
}
|
||||
|
||||
// contestModeClass collapses a mode to the CW/PH/DIG bucket contests dupe on.
|
||||
func contestModeClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "":
|
||||
return ""
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "PH", "PHONE":
|
||||
return "PH"
|
||||
default:
|
||||
return "DIG"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
|
||||
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user