Skip to content

Commit e5b3a09

Browse files
amaierhoferdaniel-illi
authored andcommitted
Add membership statistics (#344)
1 parent 73565e8 commit e5b3a09

14 files changed

Lines changed: 510 additions & 93 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (c) 2026, Schweizer Wanderwege. This file is part of
4+
# hitobito_sww and licensed under the Affero General Public License version 3
5+
# or later. See the COPYING file at the top-level directory or at
6+
# https://github.com/hitobito/hitobito_sww
7+
8+
module Sww::Group::Statistics::DateRangeFilter
9+
extend ActiveSupport::Concern
10+
11+
included do
12+
self.permitted_params += [:from, :to]
13+
14+
validates_date :from, allow_blank: true
15+
validates_date :to, allow_blank: true
16+
validates_date :to, on_or_after: :from,
17+
on_or_after_message: :date_range_invalid,
18+
if: -> { filter_params[:from].present? && errors[:to].none? }
19+
end
20+
21+
def from
22+
filter_params[:from]
23+
end
24+
25+
def to
26+
filter_params[:to]
27+
end
28+
29+
def from_date
30+
@from_date ||= parse_date(filter_params[:from]) || Time.zone.today.beginning_of_year
31+
end
32+
33+
def to_date
34+
@to_date ||= parse_date(filter_params[:to]) || Time.zone.today.end_of_year
35+
end
36+
end

app/domain/sww/group/statistics/event_participation.rb

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,9 @@
88
module Sww::Group::Statistics
99
class EventParticipation < ::Group::Statistics::Base
1010
self.key = :event_participation
11-
self.permitted_params = [:from, :to]
1211

1312
include GroupScoping
14-
15-
validates_date :from, allow_blank: true
16-
validates_date :to, allow_blank: true
17-
validates_date :to, on_or_after: :from,
18-
on_or_after_message: :date_range_invalid,
19-
if: -> { filter_params[:from].present? && errors[:to].none? }
13+
include DateRangeFilter
2014

2115
def events_count
2216
@events_count ||= events.count
@@ -73,22 +67,6 @@ def participation_frequency
7367
end
7468
end
7569

76-
def from_date
77-
@from_date ||= parse_date(filter_params[:from]) || Time.zone.today.beginning_of_year
78-
end
79-
80-
def to_date
81-
@to_date ||= parse_date(filter_params[:to]) || Time.zone.today.end_of_year
82-
end
83-
84-
def from
85-
filter_params[:from]
86-
end
87-
88-
def to
89-
filter_params[:to]
90-
end
91-
9270
private
9371

9472
alias_method :scoping_root, :layer
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (c) 2026, Schweizer Wanderwege. This file is part of
4+
# hitobito_sww and licensed under the Affero General Public License version 3
5+
# or later. See the COPYING file at the top-level directory or at
6+
# https://github.com/hitobito/hitobito_sww
7+
8+
module Sww::Group::Statistics
9+
class Memberships < ::Group::Statistics::Base
10+
self.key = :memberships
11+
self.layer_only = false
12+
13+
include DateRangeFilter
14+
15+
TotalRow = Data.define(:entries, :exits, :net)
16+
RoleRow = Data.define(:label, :entries, :exits, :net)
17+
GroupBreakdown = Data.define(:title, :role_rows, :total_row)
18+
19+
def total_entries
20+
@total_entries ||= entries_scope.where(group_id: groups.map(&:id)).count
21+
end
22+
23+
def total_exits
24+
@total_exits ||= exits_scope.where(group_id: groups.map(&:id)).count
25+
end
26+
27+
def net_change
28+
total_entries - total_exits
29+
end
30+
31+
def group_breakdowns
32+
@group_breakdowns ||= groups.map { |group| build_group_breakdown(group) }
33+
end
34+
35+
private
36+
37+
def build_group_breakdown(group)
38+
title = breadcrumb_title(group)
39+
role_rows = build_role_rows(group)
40+
total_row = TotalRow.new(
41+
entries: role_rows.sum(&:entries),
42+
exits: role_rows.sum(&:exits),
43+
net: role_rows.sum(&:net)
44+
)
45+
GroupBreakdown.new(title:, role_rows:, total_row:)
46+
end
47+
48+
def breadcrumb_title(group)
49+
group.local_hierarchy[1..].map(&:to_s).join(" → ")
50+
end
51+
52+
def build_role_rows(group)
53+
role_types_in(group.id).map do |role_type|
54+
build_role_row(group.id, role_type)
55+
end
56+
end
57+
58+
def role_types_in(group_id)
59+
(entries_by_group_and_type.keys + exits_by_group_and_type.keys)
60+
.select { |id, _| id == group_id }
61+
.map { |_, role_type| role_type }
62+
.uniq
63+
end
64+
65+
def build_role_row(group_id, type)
66+
entries = entries_by_group_and_type.fetch([group_id, type], 0)
67+
exits = exits_by_group_and_type.fetch([group_id, type], 0)
68+
net = entries - exits
69+
RoleRow.new(label: type.constantize.label, entries:, exits:, net:)
70+
end
71+
72+
def groups
73+
@groups ||= group.descendants.where(layer_group_id: layer.id).order(:lft).to_a
74+
end
75+
76+
def entries_by_group_and_type
77+
@entries_by_group_and_type ||= entries_scope.group(:group_id, :type).count
78+
end
79+
80+
def exits_by_group_and_type
81+
@exits_by_group_and_type ||= exits_scope.group(:group_id, :type).count
82+
end
83+
84+
def entries_scope
85+
::Role.with_inactive.where(start_on: from_date..to_date)
86+
end
87+
88+
def exits_scope
89+
::Role.with_inactive.where(end_on: from_date..to_date)
90+
end
91+
end
92+
end

app/helpers/sww/format_helper.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (c) 2012-2026, Schweizer Wanderwege. This file is part of
4+
# hitobito_sww and licensed under the Affero General Public License version 3
5+
# or later. See the COPYING file at the top-level directory or at
6+
# https://github.com/hitobito/hitobito_sww.
7+
8+
module Sww::FormatHelper
9+
extend ActiveSupport::Concern
10+
11+
def signed_number(value)
12+
format("%+d", value)
13+
end
14+
15+
def net_change_arrow(value)
16+
if value.positive?
17+
"↗"
18+
elsif value.negative?
19+
"↘"
20+
else
21+
"→"
22+
end
23+
end
24+
end

app/views/group/statistics/_event_participation.html.haml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
-# https://github.com/hitobito/hitobito_sww
55
66
= content_for(:filter) do
7-
= form_tag(group_statistic_path(@group, :event_participation), method: :get, class: "form-inline-search statistics-filter", role: "search", remote: true, data: { spin: true }) do
7+
= form_tag(group_statistic_path(@group, :event_participation), method: :get, class: "form-inline-search") do
88
.d-lg-flex.justify-content-between.gap-3
99
.d-lg-inline-flex.gap-3.align-items-end
1010
.mb-3
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-# Copyright (c) 2026, Schweizer Wanderwege. This file is part of
2+
-# hitobito_sww and licensed under the Affero General Public License version 3
3+
-# or later. See the COPYING file at the top-level directory or at
4+
-# https://github.com/hitobito/hitobito_sww
5+
6+
= content_for(:filter) do
7+
= form_tag(group_statistic_path(@group, :memberships), method: :get, class: "form-inline-search") do
8+
.d-lg-flex.justify-content-between.gap-3
9+
.d-lg-inline-flex.gap-3.align-items-end
10+
.mb-3
11+
= direct_filter_date(:from, t(".from"), value: statistic.filter_params[:from] || f(statistic.from_date), data: { submit: true })
12+
.mb-3
13+
= direct_filter_date(:to, t(".to"), value: statistic.filter_params[:to] || f(statistic.to_date), data: { submit: true })
14+
15+
.card.mb-3
16+
.card-header.d-flex.justify-content-between.align-items-center
17+
%h3.card-title.mb-0= @group.to_s
18+
%span= t('.period', from: l(statistic.from_date), to: l(statistic.to_date))
19+
.card-body
20+
= render_card title: t('.summary_title') do
21+
%dl.row
22+
- rows = [ |
23+
{ label_key: t('.total_entries'), value: signed_number(statistic.total_entries) }, |
24+
{ label_key: t('.total_exits'), value: signed_number(-statistic.total_exits) }, |
25+
{ label_key: t('.net_change'), |
26+
value: "#{signed_number(statistic.net_change)} #{net_change_arrow(statistic.net_change)}" } |
27+
] |
28+
= render partial: 'overview_row', collection: rows, as: :row
29+
30+
- statistic.group_breakdowns.each do |breakdown|
31+
= render_card breakdown.role_rows, title: breakdown.title do |role_rows|
32+
%table.table.table-bordered.text-end
33+
%colgroup
34+
%col{style: "width: 40%"}
35+
%col{style: "width: 20%"}
36+
%col{style: "width: 20%"}
37+
%col{style: "width: 20%"}
38+
%thead
39+
%tr
40+
%th.text-start= t('.role_type')
41+
%th= t('.entries')
42+
%th= t('.exits')
43+
%th= t('.net')
44+
%tbody
45+
- role_rows.each do |row|
46+
%tr
47+
%td.text-start= row.label
48+
%td= row.entries
49+
%td= row.exits
50+
%td= "#{signed_number(row.net)} #{net_change_arrow(row.net)}"
51+
%tr.fw-bold
52+
%td.text-start= t('.total')
53+
%td= breakdown.total_row.entries
54+
%td= breakdown.total_row.exits
55+
%td= "#{signed_number(breakdown.total_row.net)} #{net_change_arrow(breakdown.total_row.net)}"

app/views/group/statistics/_people.html.haml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
-# https://github.com/hitobito/hitobito_sww
55
66
= content_for(:filter) do
7-
= form_tag(group_statistic_path(@group, :people), method: :get, class: "form-inline-search statistics-filter", role: "search", remote: true, data: { spin: true }) do
7+
= form_tag(group_statistic_path(@group, :people), method: :get, class: "form-inline-search") do
88
.d-lg-flex.justify-content-between.gap-3
99
.d-lg-inline-flex.gap-3.align-items-end
1010
.mb-3

config/locales/models.sww.de.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,19 @@ de:
186186
to: Bis
187187
sww/group/statistics/people:
188188
date: Stichtag
189+
sww/group/statistics/memberships:
190+
from: Von
191+
to: Bis
189192
errors:
190193
models:
191194
sww/group/statistics/event_participation:
192195
attributes:
193196
to:
194197
date_range_invalid: darf nicht vor dem Von-Datum liegen
198+
sww/group/statistics/memberships:
199+
attributes:
200+
to:
201+
date_range_invalid: darf nicht vor dem Von-Datum liegen
195202

196203

197204
tags:

config/locales/views.sww.de.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ de:
8989
share: Anteil
9090
age_structure_title: Altersstruktur
9191
age_range: Alter
92+
memberships:
93+
title: Ein- und Austritte
94+
from: Von
95+
to: Bis
96+
period: "Zeitraum: %{from} - %{to}"
97+
summary_title: Zusammenfassung
98+
total_entries: "Total Eintritte"
99+
total_exits: "Total Austritte"
100+
net_change: "Netto-Veränderung"
101+
role_type: Rollentyp
102+
entries: Eintritte
103+
exits: Austritte
104+
net: Netto
105+
total: Total
92106

93107
groups:
94108
form_tabs:

lib/hitobito_sww/wagon.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ class Wagon < Rails::Engine
114114
::Group::Statistics::Registry.statistics.delete(::Group::Statistics::Demographic)
115115
::Group::Statistics::Registry.register(
116116
Sww::Group::Statistics::People,
117-
Sww::Group::Statistics::EventParticipation
117+
Sww::Group::Statistics::EventParticipation,
118+
Sww::Group::Statistics::Memberships
118119
)
119120
end
120121

0 commit comments

Comments
 (0)