feat: Adding French language

This commit is contained in:
2026-07-05 03:07:44 +02:00
parent e590a58702
commit 3a6afc28ac
11 changed files with 696 additions and 171 deletions
+164
View File
@@ -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) })
}