fix: activity chart uses rolling chronological windows (real dates), not cyclical

Day/Week/Month/Year now show the anchor day hour-by-hour, the last 7 days, the
last 30 days, and the last 12 months — anchored to the period end (else now), in
real date order. Replaces the cyclical hour-of-day/weekday/day-of-month/month
buckets, which left future days empty and ordered the weekend after Monday.
This commit is contained in:
2026-07-20 21:39:24 +02:00
parent f0c4f22942
commit bfbd9fa61a
4 changed files with 53 additions and 37 deletions
+3 -3
View File
@@ -32,7 +32,7 @@ type Stats = {
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number; confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[]; by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[]; by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_dow: Bucket[]; by_dom: Bucket[]; by_month_yr: Bucket[]; by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Period / contest metrics. // Period / contest metrics.
window_start: string; window_end: string; window_hours: number; window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number; avg_per_hour: number; avg_per_active: number;
@@ -518,7 +518,7 @@ const ACT_GRAN = [
] as const; ] as const;
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) { function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline'); const [g, setG] = useState<string>('timeline');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_dow : g === 'month' ? stats.by_dom : g === 'year' ? stats.by_month_yr : []; const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
return ( return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)"> <Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
@@ -602,7 +602,7 @@ export function StatsPanel() {
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator), by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent), by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month), top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_dow: arr(raw.by_dow), by_dom: arr(raw.by_dom), by_month_yr: arr(raw.by_month_yr), by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
rate: arr(raw.rate), gaps: arr(raw.gaps), rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats); } as Stats);
+2 -2
View File
@@ -65,7 +65,7 @@ const en: Dict = {
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)', 'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent', 'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year', 'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or by hour / weekday / day / month', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year', 'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
'stats.byContinentSub': 'Share of the log', 'stats.byContinentSub': 'Share of the log',
@@ -379,7 +379,7 @@ const fr: Dict = {
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas dOPERATOR renseigné)', 'stats.byOperatorSub': '« — » = loggé par le titulaire (pas dOPERATOR renseigné)',
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent', 'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année', 'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou par heure / jour sem. / jour / mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année', 'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier', 'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
'stats.byContinentSub': 'Part du journal', 'stats.byContinentSub': 'Part du journal',
+6 -6
View File
@@ -3928,9 +3928,9 @@ export namespace qso {
by_year: Bucket[]; by_year: Bucket[];
by_month: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_hour: Bucket[];
by_dow: Bucket[]; by_day7: Bucket[];
by_dom: Bucket[]; by_day30: Bucket[];
by_month_yr: Bucket[]; by_month12: Bucket[];
window_start: string; window_start: string;
window_end: string; window_end: string;
window_hours: number; window_hours: number;
@@ -3972,9 +3972,9 @@ export namespace qso {
this.by_year = this.convertValues(source["by_year"], Bucket); this.by_year = this.convertValues(source["by_year"], Bucket);
this.by_month = this.convertValues(source["by_month"], Bucket); this.by_month = this.convertValues(source["by_month"], Bucket);
this.by_hour = this.convertValues(source["by_hour"], Bucket); this.by_hour = this.convertValues(source["by_hour"], Bucket);
this.by_dow = this.convertValues(source["by_dow"], Bucket); this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_dom = this.convertValues(source["by_dom"], Bucket); this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month_yr = this.convertValues(source["by_month_yr"], Bucket); this.by_month12 = this.convertValues(source["by_month12"], Bucket);
this.window_start = source["window_start"]; this.window_start = source["window_start"];
this.window_end = source["window_end"]; this.window_end = source["window_end"];
this.window_hours = source["window_hours"]; this.window_hours = source["window_hours"];
+42 -26
View File
@@ -163,12 +163,13 @@ type Stats struct {
ByYear []Bucket `json:"by_year"` // chronological ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// Cyclical activity distributions (UTC), for the "activity over time" chart's // Rolling chronological activity windows (UTC), anchored to the newest QSO, for
// day/week/month/year granularity selector. // the "activity over time" chart's day/week/month/year granularity selector.
ByHour []Bucket `json:"by_hour"` // hour of day 00..23 // Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
ByDOW []Bucket `json:"by_dow"` // day of week Mon..Sun ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
ByDOM []Bucket `json:"by_dom"` // day of month 1..31 ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
ByMonthYr []Bucket `json:"by_month_yr"` // month Jan..Dec ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
// ── Period / contest metrics ── // ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says // Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
@@ -476,40 +477,55 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
s.ByMonth = fillMonths(monthC, first, last) s.ByMonth = fillMonths(monthC, first, last)
s.periodMetrics(times, from, to, first, last) s.periodMetrics(times, from, to, first, last)
s.timeDistributions(times) // Anchor the rolling activity windows to the period end when filtered, else to
// now (so "week" is the last 7 days ending today, like the operator expects).
ref := to
if ref.IsZero() {
ref = time.Now()
}
s.recentSeries(times, ref)
s.ensureNonNil() s.ensureNonNil()
return s, nil return s, nil
} }
// timeDistributions builds the cyclical activity buckets (hour-of-day, day-of-week, // recentSeries builds the rolling chronological activity windows for the chart's
// day-of-month, month-of-year) from the dated QSOs, in UTC. Each is a fixed-length // day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// series so the chart axis is stable even for empty slots. // filtered, else now). Real dates in order — today and the days before it — so the
func (s *Stats) timeDistributions(times []entry) { // axis reads chronologically and empty days are just zero, not misordered/wrapped.
func (s *Stats) recentSeries(times []entry, ref time.Time) {
ref = ref.UTC()
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
hour := make([]int, 24) hour := make([]int, 24)
dow := make([]int, 7) // 0 = Monday daily := map[string]int{}
dom := make([]int, 31) monthly := map[string]int{}
moy := make([]int, 12)
for _, e := range times { for _, e := range times {
t := e.t.UTC() t := e.t.UTC()
hour[t.Hour()]++ if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
dow[(int(t.Weekday())+6)%7]++ // shift Sunday(0) → 6 so Monday is first hour[t.Hour()]++
if d := t.Day(); d >= 1 && d <= 31 {
dom[d-1]++
} }
moy[int(t.Month())-1]++ daily[t.Format("2006-01-02")]++
monthly[t.Format("2006-01")]++
} }
// Day: the anchor day, hour by hour.
for h := 0; h < 24; h++ { for h := 0; h < 24; h++ {
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02d", h), Count: hour[h]}) s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
} }
for i, n := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} { // Week: the last 7 days ending today (today is last). Label = weekday + day.
s.ByDOW = append(s.ByDOW, Bucket{Key: n, Count: dow[i]}) for i := 6; i >= 0; i-- {
d := day0.AddDate(0, 0, -i)
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
} }
for d := 1; d <= 31; d++ { // Month: the last 30 days.
s.ByDOM = append(s.ByDOM, Bucket{Key: fmt.Sprintf("%d", d), Count: dom[d-1]}) for i := 29; i >= 0; i-- {
d := day0.AddDate(0, 0, -i)
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
} }
for i, n := range []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} { // Year: the last 12 months.
s.ByMonthYr = append(s.ByMonthYr, Bucket{Key: n, Count: moy[i]}) m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
for i := 11; i >= 0; i-- {
m := m0.AddDate(0, -i, 0)
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
} }
} }