feat: stats timeline, band-map colours, frameless window, and four fixes
Statistics — the activity chart ignored the period selector: picking a year up top left it showing the last 7 days, two controls contradicting each other. The buttons were four fixed rolling windows anchored on the newest QSO, not granularities. They now choose the BUCKET SIZE over the selected period, with an Auto default and a step-up when a bucket would be too fine (the backend drops a series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day histogram covered a single day, too little to read anything from; it moves to its own card with a weekday view, over the whole period. Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the same amber that means "new band" on the pills drawn on top of them. A sub-band is an identity, not a state: categorical hues now, validated in both themes (violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and added to the legend, since identity must never be colour-alone. Frameless window — the OS title bar was a dead 32px band above a window that already has its own. Minimise/maximise/close move into the app header, which carries the drag region and double-click-to-maximise. The drag opt-out is by ROLE in CSS, so a future button in that bar keeps working without anyone remembering the rule. Fixes: - Saving settings froze the window while a device was slow: restarting a link waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM Connect when another program holds the rig. The restart is now off the UI path; a slow one is logged. - Multi-screen: a MAXIMISED window was never repositioned, so it came back on the primary screen every launch. The corner is now recorded even when maximised (it names the monitor) and re-applied while still hidden. - The Callsign field is marked out at rest — operators were typing the call into Name, which sat beside it and looked identical. - QSL dates get a real picker (native, for the OS calendar and locale order); a malformed old value stays in a text box rather than vanishing behind an empty one. Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2 report (one element then a ten-second pause, sidetone that will not switch off) is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would break the operators it works for today. The trace logs every byte with the command name and spells out the firmware family, which is what will settle it.
This commit is contained in:
+145
-32
@@ -156,28 +156,49 @@ type Stats struct {
|
||||
ByMode []Bucket `json:"by_mode"`
|
||||
ByBand []Bucket `json:"by_band"`
|
||||
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||
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
|
||||
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
|
||||
|
||||
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||
|
||||
// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
|
||||
// size. These are what the activity chart's day/week/month/year selector
|
||||
// drives: the rolling windows above ignored the period filter, so choosing a
|
||||
// year in the panel left the chart showing the last 7 days regardless — two
|
||||
// controls contradicting each other on screen.
|
||||
//
|
||||
// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
|
||||
// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
|
||||
// The UI treats an empty series as "too fine for this period" and steps up.
|
||||
ByDayWin []Bucket `json:"by_day_win"`
|
||||
ByWeekWin []Bucket `json:"by_week_win"`
|
||||
ByMonthWin []Bucket `json:"by_month_win"`
|
||||
ByYearWin []Bucket `json:"by_year_win"`
|
||||
|
||||
// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
|
||||
// question from the above ("how much, lately"), so it gets its own card. The
|
||||
// hour histogram used to cover a single day, which is too little to read
|
||||
// anything from.
|
||||
ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
|
||||
ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun
|
||||
|
||||
// ── 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"`
|
||||
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
|
||||
|
||||
@@ -297,28 +318,28 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
calls = map[string]struct{}{}
|
||||
entities = map[int]struct{}{}
|
||||
modeC = map[string]int{}
|
||||
bandC = map[string]int{}
|
||||
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||
opC = map[string]int{}
|
||||
stationC = map[string]int{}
|
||||
contC = map[string]int{}
|
||||
entityC = map[string]int{}
|
||||
yearC = map[string]int{}
|
||||
calls = map[string]struct{}{}
|
||||
entities = map[int]struct{}{}
|
||||
modeC = map[string]int{}
|
||||
bandC = map[string]int{}
|
||||
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||
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 []entry // every dated QSO (+ its operator), for the rate / gap maths
|
||||
times []entry // every dated QSO (+ its operator), 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
|
||||
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 {
|
||||
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
ref = time.Now()
|
||||
}
|
||||
s.recentSeries(times, ref)
|
||||
s.windowSeries(times, first, last, from, to)
|
||||
s.ensureNonNil()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// maxActivityBuckets caps a chronological series. Beyond this the chart is a
|
||||
// grey smear and the payload grows for nothing; the series is dropped and the UI
|
||||
// steps up to a coarser bucket.
|
||||
const maxActivityBuckets = 750
|
||||
|
||||
// windowSeries builds the chronological activity series ACROSS THE SELECTED
|
||||
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
|
||||
//
|
||||
// The period is [from,to] when the caller filtered, else the span of the log —
|
||||
// the same window the period metrics use, so every card on the panel answers for
|
||||
// the same slice of time.
|
||||
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
|
||||
start, end := from, to
|
||||
if start.IsZero() {
|
||||
start = first
|
||||
}
|
||||
if end.IsZero() {
|
||||
end = last
|
||||
}
|
||||
if start.IsZero() || end.IsZero() || end.Before(start) {
|
||||
return
|
||||
}
|
||||
start, end = start.UTC(), end.UTC()
|
||||
|
||||
day := map[string]int{}
|
||||
week := map[string]int{}
|
||||
month := map[string]int{}
|
||||
year := map[string]int{}
|
||||
hour := make([]int, 24)
|
||||
weekday := make([]int, 7)
|
||||
for _, e := range times {
|
||||
t := e.t.UTC()
|
||||
if t.Before(start) || t.After(end) {
|
||||
continue
|
||||
}
|
||||
day[t.Format("2006-01-02")]++
|
||||
// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
|
||||
wy, wn := t.ISOWeek()
|
||||
week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
|
||||
month[t.Format("2006-01")]++
|
||||
year[t.Format("2006")]++
|
||||
hour[t.Hour()]++
|
||||
weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
|
||||
}
|
||||
|
||||
// Every bucket in the range is emitted, including the empty ones: a gap in
|
||||
// the log is real information and must show as zero, not be closed up.
|
||||
d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
|
||||
if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
|
||||
for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
|
||||
s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
|
||||
}
|
||||
}
|
||||
// Weeks: start on the Monday of the first week and step by 7 days.
|
||||
w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
|
||||
if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
|
||||
for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
|
||||
wy, wn := w.ISOWeek()
|
||||
key := fmt.Sprintf("%04d-W%02d", wy, wn)
|
||||
s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
|
||||
}
|
||||
}
|
||||
m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
|
||||
for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
|
||||
s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
|
||||
}
|
||||
}
|
||||
for y := start.Year(); y <= end.Year(); y++ {
|
||||
k := fmt.Sprintf("%04d", y)
|
||||
s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
|
||||
}
|
||||
|
||||
for h := 0; h < 24; h++ {
|
||||
s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||
}
|
||||
for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||
s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
|
||||
}
|
||||
}
|
||||
|
||||
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
|
||||
if s.ByMonth == nil {
|
||||
s.ByMonth = []Bucket{}
|
||||
}
|
||||
// A window series stays EMPTY on purpose when the bucket would be too fine
|
||||
// for the period — but it must be [] and not null, so the UI can tell
|
||||
// "too fine" from "not computed".
|
||||
for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
|
||||
if *p == nil {
|
||||
*p = []Bucket{}
|
||||
}
|
||||
}
|
||||
if s.Rate == nil {
|
||||
s.Rate = []Bucket{}
|
||||
}
|
||||
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
|
||||
// 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
|
||||
// - 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 []entry, from, to, first, last time.Time) {
|
||||
if len(times) == 0 {
|
||||
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user