feat: stats activity-over-time granularity selector (timeline / day / week / month / year)
Backend adds cyclical distributions ByHour (hour-of-day), ByDOW (weekday), ByDOM (day-of-month) and ByMonthYr (calendar month), all UTC. The Activity-over-time card gains a selector: Timeline keeps the chronological month trend; Day/Week/ Month/Year show the hour/weekday/day/month distribution as bars.
This commit is contained in:
@@ -32,6 +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[];
|
||||||
// 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;
|
||||||
@@ -504,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
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 : [];
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{g === 'timeline'
|
||||||
|
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -569,6 +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),
|
||||||
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);
|
||||||
@@ -786,9 +820,7 @@ export function StatsPanel() {
|
|||||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
|||||||
@@ -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': 'QSOs per month',
|
'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.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 d’OPERATOR renseigné)',
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR 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': 'QSO par mois',
|
'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.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||||
|
|
||||||
'stats.byContinentSub': 'Part du journal',
|
'stats.byContinentSub': 'Part du journal',
|
||||||
|
|||||||
@@ -3927,6 +3927,10 @@ export namespace qso {
|
|||||||
top_entities: Bucket[];
|
top_entities: Bucket[];
|
||||||
by_year: Bucket[];
|
by_year: Bucket[];
|
||||||
by_month: Bucket[];
|
by_month: Bucket[];
|
||||||
|
by_hour: Bucket[];
|
||||||
|
by_dow: Bucket[];
|
||||||
|
by_dom: Bucket[];
|
||||||
|
by_month_yr: Bucket[];
|
||||||
window_start: string;
|
window_start: string;
|
||||||
window_end: string;
|
window_end: string;
|
||||||
window_hours: number;
|
window_hours: number;
|
||||||
@@ -3967,6 +3971,10 @@ export namespace qso {
|
|||||||
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
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_dow = this.convertValues(source["by_dow"], Bucket);
|
||||||
|
this.by_dom = this.convertValues(source["by_dom"], Bucket);
|
||||||
|
this.by_month_yr = this.convertValues(source["by_month_yr"], 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"];
|
||||||
|
|||||||
@@ -163,6 +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
|
||||||
|
// day/week/month/year granularity selector.
|
||||||
|
ByHour []Bucket `json:"by_hour"` // hour of day 00..23
|
||||||
|
ByDOW []Bucket `json:"by_dow"` // day of week Mon..Sun
|
||||||
|
ByDOM []Bucket `json:"by_dom"` // day of month 1..31
|
||||||
|
ByMonthYr []Bucket `json:"by_month_yr"` // month Jan..Dec
|
||||||
|
|
||||||
// ── 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
|
||||||
// nothing, but across a contest weekend it is the score. The window is the
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
@@ -469,11 +476,43 @@ 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)
|
||||||
s.ensureNonNil()
|
s.ensureNonNil()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timeDistributions builds the cyclical activity buckets (hour-of-day, day-of-week,
|
||||||
|
// day-of-month, month-of-year) from the dated QSOs, in UTC. Each is a fixed-length
|
||||||
|
// series so the chart axis is stable even for empty slots.
|
||||||
|
func (s *Stats) timeDistributions(times []entry) {
|
||||||
|
hour := make([]int, 24)
|
||||||
|
dow := make([]int, 7) // 0 = Monday
|
||||||
|
dom := make([]int, 31)
|
||||||
|
moy := make([]int, 12)
|
||||||
|
for _, e := range times {
|
||||||
|
t := e.t.UTC()
|
||||||
|
hour[t.Hour()]++
|
||||||
|
dow[(int(t.Weekday())+6)%7]++ // shift Sunday(0) → 6 so Monday is first
|
||||||
|
if d := t.Day(); d >= 1 && d <= 31 {
|
||||||
|
dom[d-1]++
|
||||||
|
}
|
||||||
|
moy[int(t.Month())-1]++
|
||||||
|
}
|
||||||
|
for h := 0; h < 24; h++ {
|
||||||
|
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02d", h), Count: hour[h]})
|
||||||
|
}
|
||||||
|
for i, n := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||||
|
s.ByDOW = append(s.ByDOW, Bucket{Key: n, Count: dow[i]})
|
||||||
|
}
|
||||||
|
for d := 1; d <= 31; d++ {
|
||||||
|
s.ByDOM = append(s.ByDOM, Bucket{Key: fmt.Sprintf("%d", d), Count: dom[d-1]})
|
||||||
|
}
|
||||||
|
for i, n := range []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} {
|
||||||
|
s.ByMonthYr = append(s.ByMonthYr, Bucket{Key: n, Count: moy[i]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ensureNonNil replaces every nil slice with an empty one.
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
//
|
//
|
||||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
|||||||
Reference in New Issue
Block a user