deat: Added statistics on your log
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Statistics over the whole logbook.
|
||||
//
|
||||
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
|
||||
// BYs. Two reasons: the date maths (year / month / hour of day) would need
|
||||
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
|
||||
// which is exactly the kind of thing that silently works on one backend and
|
||||
// breaks on the other; and a single pass over a few columns of a 30k-row log is
|
||||
// a few tens of milliseconds, so the complexity buys nothing.
|
||||
|
||||
// Bucket is one labelled count (mode, band, operator, entity…).
|
||||
type Bucket struct {
|
||||
Key string `json:"key"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||
// are the expensive minutes: they are where the score went.
|
||||
type Gap struct {
|
||||
Start string `json:"start"` // RFC3339 — the last QSO before the silence
|
||||
End string `json:"end"` // the first QSO after it
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// ContestRun is one contest the operator actually took part in, discovered FROM
|
||||
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
|
||||
// the picker only ever offers contests you really entered, and never an empty one.
|
||||
type ContestRun struct {
|
||||
ID string `json:"id"`
|
||||
Year int `json:"year"`
|
||||
Count int `json:"count"`
|
||||
Start string `json:"start"` // first QSO, RFC3339
|
||||
End string `json:"end"` // last QSO
|
||||
}
|
||||
|
||||
// ContestRuns lists every (contest, year) pair present in the logbook, most
|
||||
// recent first.
|
||||
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type key struct {
|
||||
id string
|
||||
year int
|
||||
}
|
||||
agg := map[key]*ContestRun{}
|
||||
for rows.Next() {
|
||||
var id, dateStr sql.NullString
|
||||
if err := rows.Scan(&id, &dateStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cid := strings.ToUpper(strings.TrimSpace(id.String))
|
||||
if cid == "" {
|
||||
continue
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() {
|
||||
continue
|
||||
}
|
||||
k := key{cid, t.Year()}
|
||||
c, ok := agg[k]
|
||||
if !ok {
|
||||
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
|
||||
agg[k] = c
|
||||
}
|
||||
c.Count++
|
||||
if t.Format(time.RFC3339) < c.Start {
|
||||
c.Start = t.Format(time.RFC3339)
|
||||
}
|
||||
if t.Format(time.RFC3339) > c.End {
|
||||
c.End = t.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ContestRun, 0, len(agg))
|
||||
for _, c := range agg {
|
||||
out = append(out, *c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Year != out[j].Year {
|
||||
return out[i].Year > out[j].Year // most recent first
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gapThreshold is the silence that counts as "off the air". Short enough to catch
|
||||
// a real break, long enough not to flag the normal pause between two QSOs.
|
||||
const gapThreshold = 30 * time.Minute
|
||||
|
||||
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
|
||||
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
|
||||
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
|
||||
// looks broken, which is exactly how it first shipped. Past this the UI says
|
||||
// "period too long for an hourly chart" instead of drawing mush.
|
||||
const rateMaxHours = 7 * 24
|
||||
|
||||
// Stats is the whole dashboard payload.
|
||||
type Stats struct {
|
||||
// Headline figures.
|
||||
Total int `json:"total"`
|
||||
UniqueCalls int `json:"unique_calls"`
|
||||
Entities int `json:"entities"` // distinct DXCC entities
|
||||
Continents int `json:"continents"` // distinct continents
|
||||
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
|
||||
LastQSO string `json:"last_qso"`
|
||||
|
||||
// Confirmations (of Total).
|
||||
ConfirmedLoTW int `json:"confirmed_lotw"`
|
||||
ConfirmedEQSL int `json:"confirmed_eqsl"`
|
||||
ConfirmedQSL int `json:"confirmed_qsl"`
|
||||
ConfirmedAny int `json:"confirmed_any"`
|
||||
|
||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||
ByMode []Bucket `json:"by_mode"`
|
||||
ByBand []Bucket `json:"by_band"`
|
||||
ByOperator []Bucket `json:"by_operator"`
|
||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||
ByContinent []Bucket `json:"by_continent"`
|
||||
TopEntities []Bucket `json:"top_entities"`
|
||||
ByYear []Bucket `json:"by_year"` // chronological
|
||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||
|
||||
// ── Period / contest metrics ──
|
||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||
// nothing, but across a contest weekend it is the score. The window is the
|
||||
// requested [from,to] when given, else the span of the log.
|
||||
WindowStart string `json:"window_start"`
|
||||
WindowEnd string `json:"window_end"`
|
||||
WindowHours float64 `json:"window_hours"`
|
||||
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
||||
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
||||
|
||||
// On-air and off-air are a TIME BUDGET and must add up to the window:
|
||||
// OnAirMinutes + OffAirMinutes == window
|
||||
// The first version counted "clock hours containing at least one QSO" as on-air,
|
||||
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
|
||||
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
|
||||
// measured on incompatible bases can't be compared, and the operator rightly
|
||||
// didn't believe either of them.
|
||||
OnAirMinutes int `json:"on_air_minutes"`
|
||||
OffAirMinutes int `json:"off_air_minutes"`
|
||||
|
||||
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
|
||||
PeakHourCount int `json:"peak_hour_count"`
|
||||
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
||||
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
||||
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
||||
}
|
||||
|
||||
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
||||
// so the band chart reads like a band plan instead of a jumble.
|
||||
var bandOrder = map[string]int{
|
||||
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
|
||||
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
|
||||
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
|
||||
}
|
||||
|
||||
// yes reports whether an ADIF confirmation flag means "confirmed".
|
||||
func yes(s string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "Y", "V": // V = verified (LoTW)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stats scans the logbook once and returns every breakdown the dashboard needs,
|
||||
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
|
||||
// the whole log).
|
||||
//
|
||||
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
|
||||
// WHERE. qso_date is a text column whose format differs between the two backends,
|
||||
// so a string comparison would quietly select the wrong rows on one of them. We
|
||||
// already parse every date in this pass; filtering on the parsed value is both
|
||||
// correct and free.
|
||||
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
|
||||
// it is set and no explicit window is given, the window becomes the contest's own
|
||||
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||
// itself without the operator having to look its dates up.
|
||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||
var s Stats
|
||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
|
||||
FROM qso`)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
calls = map[string]struct{}{}
|
||||
entities = map[int]struct{}{}
|
||||
modeC = map[string]int{}
|
||||
bandC = map[string]int{}
|
||||
opC = map[string]int{}
|
||||
stationC = map[string]int{}
|
||||
contC = map[string]int{}
|
||||
entityC = map[string]int{}
|
||||
yearC = map[string]int{}
|
||||
monthC = map[string]int{}
|
||||
times []time.Time // every dated QSO, for the rate / gap maths
|
||||
first, last time.Time
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
call, band, mode, cont, country sql.NullString
|
||||
oper, station sql.NullString
|
||||
lotw, eqsl, paper sql.NullString
|
||||
dxcc sql.NullInt64
|
||||
dateStr, contestID2 sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
||||
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Contest filter first — same reasoning as the window below: a QSO that
|
||||
// isn't in this contest must not reach ANY bucket.
|
||||
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||
// operator charts showing the whole log while only the trend was filtered.
|
||||
// parseTimeLoose is the repo's existing convention for qso_date — it copes
|
||||
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
dated := !t.IsZero()
|
||||
if year > 0 && (!dated || t.Year() != year) {
|
||||
continue
|
||||
}
|
||||
if !from.IsZero() && (!dated || t.Before(from)) {
|
||||
continue
|
||||
}
|
||||
if !to.IsZero() && (!dated || t.After(to)) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.Total++
|
||||
|
||||
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
|
||||
calls[c] = struct{}{}
|
||||
}
|
||||
if dxcc.Valid && dxcc.Int64 > 0 {
|
||||
entities[int(dxcc.Int64)] = struct{}{}
|
||||
}
|
||||
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
|
||||
modeC[m]++
|
||||
}
|
||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||
bandC[b]++
|
||||
}
|
||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||
// it explicitly rather than dropping the QSO from the operator chart.
|
||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
opC[op]++
|
||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||
stationC[st]++
|
||||
}
|
||||
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||
contC[c]++
|
||||
}
|
||||
if c := strings.TrimSpace(country.String); c != "" {
|
||||
entityC[c]++
|
||||
}
|
||||
|
||||
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
|
||||
if cl {
|
||||
s.ConfirmedLoTW++
|
||||
}
|
||||
if el {
|
||||
s.ConfirmedEQSL++
|
||||
}
|
||||
if pl {
|
||||
s.ConfirmedQSL++
|
||||
}
|
||||
if cl || el || pl {
|
||||
s.ConfirmedAny++
|
||||
}
|
||||
|
||||
// An undated QSO still counts in the mode/band/operator totals above, but
|
||||
// it can't be placed on a time axis — leave it out of the trend rather than
|
||||
// parking it at year zero.
|
||||
if !dated {
|
||||
continue
|
||||
}
|
||||
if first.IsZero() || t.Before(first) {
|
||||
first = t
|
||||
}
|
||||
if last.IsZero() || t.After(last) {
|
||||
last = t
|
||||
}
|
||||
yearC[t.Format("2006")]++
|
||||
monthC[t.Format("2006-01")]++
|
||||
times = append(times, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.UniqueCalls = len(calls)
|
||||
s.Entities = len(entities)
|
||||
s.Continents = len(contC)
|
||||
if !first.IsZero() {
|
||||
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
s.ByMode = topBuckets(modeC, 0)
|
||||
s.ByOperator = topBuckets(opC, 0)
|
||||
s.ByStation = topBuckets(stationC, 0)
|
||||
s.ByContinent = topBuckets(contC, 0)
|
||||
s.TopEntities = topBuckets(entityC, 15)
|
||||
|
||||
// Bands read in band-plan order, not by count — the shape of the chart IS
|
||||
// the band plan, and re-sorting it by size would destroy that.
|
||||
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
|
||||
oa, ob := bandOrder[a], bandOrder[b]
|
||||
if oa == 0 {
|
||||
oa = 99
|
||||
}
|
||||
if ob == 0 {
|
||||
ob = 99
|
||||
}
|
||||
if oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||
// chart would invent activity that never happened. A gap in the log is real
|
||||
// information: it belongs on the chart as zeros.
|
||||
s.ByYear = fillYears(yearC, first, last)
|
||||
s.ByMonth = fillMonths(monthC, first, last)
|
||||
|
||||
s.periodMetrics(times, from, to, first, last)
|
||||
s.ensureNonNil()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ensureNonNil replaces every nil slice with an empty one.
|
||||
//
|
||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
|
||||
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
|
||||
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
|
||||
// (Rate nil). The awards code carries the same guard for the same reason.
|
||||
func (s *Stats) ensureNonNil() {
|
||||
if s.ByMode == nil {
|
||||
s.ByMode = []Bucket{}
|
||||
}
|
||||
if s.ByBand == nil {
|
||||
s.ByBand = []Bucket{}
|
||||
}
|
||||
if s.ByOperator == nil {
|
||||
s.ByOperator = []Bucket{}
|
||||
}
|
||||
if s.ByStation == nil {
|
||||
s.ByStation = []Bucket{}
|
||||
}
|
||||
if s.ByContinent == nil {
|
||||
s.ByContinent = []Bucket{}
|
||||
}
|
||||
if s.TopEntities == nil {
|
||||
s.TopEntities = []Bucket{}
|
||||
}
|
||||
if s.ByYear == nil {
|
||||
s.ByYear = []Bucket{}
|
||||
}
|
||||
if s.ByMonth == nil {
|
||||
s.ByMonth = []Bucket{}
|
||||
}
|
||||
if s.Rate == nil {
|
||||
s.Rate = []Bucket{}
|
||||
}
|
||||
if s.Gaps == nil {
|
||||
s.Gaps = []Gap{}
|
||||
}
|
||||
}
|
||||
|
||||
// periodMetrics derives the rate / off-air figures that make a contest window
|
||||
// readable. The window is the caller's [from,to] when given, else the span of the
|
||||
// log itself.
|
||||
//
|
||||
// Two rates are reported on purpose, because a single one always flatters:
|
||||
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||
// how fast you go when you ARE at the radio.
|
||||
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||
func (s *Stats) periodMetrics(times []time.Time, from, to, first, last time.Time) {
|
||||
if len(times) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
||||
|
||||
winStart, winEnd := from, to
|
||||
if winStart.IsZero() {
|
||||
winStart = first
|
||||
}
|
||||
if winEnd.IsZero() {
|
||||
winEnd = last
|
||||
}
|
||||
if !winEnd.After(winStart) {
|
||||
return
|
||||
}
|
||||
s.WindowStart = winStart.Format(time.RFC3339)
|
||||
s.WindowEnd = winEnd.Format(time.RFC3339)
|
||||
s.WindowHours = winEnd.Sub(winStart).Hours()
|
||||
if s.WindowHours > 0 {
|
||||
s.AvgPerHour = float64(len(times)) / s.WindowHours
|
||||
}
|
||||
|
||||
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
||||
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
||||
hourly := map[string]int{}
|
||||
for _, t := range times {
|
||||
hourly[t.Format("2006-01-02 15")]++
|
||||
}
|
||||
for k, v := range hourly {
|
||||
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
||||
s.PeakHourKey, s.PeakHourCount = k, v
|
||||
}
|
||||
}
|
||||
|
||||
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45–
|
||||
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
|
||||
// actually quote. Two pointers over the sorted times: O(n).
|
||||
lo := 0
|
||||
for hi := range times {
|
||||
for times[hi].Sub(times[lo]) >= time.Hour {
|
||||
lo++
|
||||
}
|
||||
if n := hi - lo + 1; n > s.Best60 {
|
||||
s.Best60 = n
|
||||
}
|
||||
}
|
||||
|
||||
// Off-air is a TIME BUDGET, and it has to close on the window:
|
||||
// OnAirMinutes + OffAirMinutes == window
|
||||
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
|
||||
// and the tail after the last, when an explicit window was asked for. Skipping
|
||||
// those (the first version did) makes "on air" and "off air" sum to more than
|
||||
// the window, and then neither number is believable.
|
||||
addGap := func(a, b time.Time) {
|
||||
d := b.Sub(a)
|
||||
if d < gapThreshold {
|
||||
return
|
||||
}
|
||||
s.OffAirMinutes += int(d.Minutes())
|
||||
s.Gaps = append(s.Gaps, Gap{
|
||||
Start: a.Format(time.RFC3339),
|
||||
End: b.Format(time.RFC3339),
|
||||
Minutes: int(d.Minutes()),
|
||||
})
|
||||
}
|
||||
addGap(winStart, times[0]) // lead-in
|
||||
for i := 1; i < len(times); i++ { // the silences between QSOs
|
||||
addGap(times[i-1], times[i])
|
||||
}
|
||||
addGap(times[len(times)-1], winEnd) // tail
|
||||
|
||||
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
||||
if s.OnAirMinutes < 0 {
|
||||
s.OnAirMinutes = 0
|
||||
}
|
||||
if s.OnAirMinutes > 0 {
|
||||
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
|
||||
}
|
||||
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
|
||||
if len(s.Gaps) > 10 {
|
||||
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
||||
}
|
||||
|
||||
// Per-hour rate timeline — the classic contest rate chart. Every hour of the
|
||||
// window, zeros included, so the silences are visible as silences.
|
||||
if s.WindowHours <= rateMaxHours {
|
||||
cur := winStart.Truncate(time.Hour)
|
||||
end := winEnd.Truncate(time.Hour)
|
||||
for !cur.After(end) {
|
||||
s.Rate = append(s.Rate, Bucket{
|
||||
Key: cur.Format("01-02 15"),
|
||||
Count: hourly[cur.Format("2006-01-02 15")],
|
||||
})
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
||||
// keeps only the top n.
|
||||
func topBuckets(m map[string]int, n int) []Bucket {
|
||||
out := make([]Bucket, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, Bucket{Key: k, Count: v})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return out[i].Key < out[j].Key
|
||||
})
|
||||
if n > 0 && len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
|
||||
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
|
||||
out := make([]Bucket, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
||||
// so the trend line's x-axis is real time rather than "months that happen to have
|
||||
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
|
||||
if first.IsZero() {
|
||||
return nil
|
||||
}
|
||||
var out []Bucket
|
||||
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
for !cur.After(end) {
|
||||
k := cur.Format("2006-01")
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
cur = cur.AddDate(0, 1, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillYears does the same for the yearly view.
|
||||
func fillYears(m map[string]int, first, last time.Time) []Bucket {
|
||||
if first.IsZero() {
|
||||
return nil
|
||||
}
|
||||
var out []Bucket
|
||||
for y := first.Year(); y <= last.Year(); y++ {
|
||||
k := fmt.Sprintf("%04d", y)
|
||||
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user