Skip to content

Commit 7f30fa3

Browse files
Add memberships statistic (#344)
--------- Co-authored-by: Daniel Illi <daniel@illi.zone>
1 parent 73565e8 commit 7f30fa3

14 files changed

Lines changed: 566 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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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, :count) do
16+
def blank? = entries.zero? && exits.zero? && count.zero?
17+
end
18+
RoleRow = Data.define(:label, :entries, :exits, :net, :count)
19+
GroupBreakdown = Data.define(:title, :role_rows, :total_row)
20+
21+
def total_entries
22+
@total_entries ||= entries_scope.where(group_id: groups.map(&:id)).count
23+
end
24+
25+
def total_exits
26+
@total_exits ||= exits_scope.where(group_id: groups.map(&:id)).count
27+
end
28+
29+
def net_change
30+
total_entries - total_exits
31+
end
32+
33+
def total_count
34+
@total_count ||= actives_scope.where(group_id: groups.map(&:id)).count
35+
end
36+
37+
def group_breakdowns
38+
@group_breakdowns ||= groups.map { |group| build_group_breakdown(group) }
39+
.reject { |breakdown| breakdown.total_row.blank? }
40+
end
41+
42+
private
43+
44+
def build_group_breakdown(group)
45+
title = breadcrumb_title(group)
46+
role_rows = build_role_rows(group)
47+
total_row = TotalRow.new(
48+
entries: role_rows.sum(&:entries),
49+
exits: role_rows.sum(&:exits),
50+
net: role_rows.sum(&:net),
51+
count: role_rows.sum(&:count)
52+
)
53+
GroupBreakdown.new(title:, role_rows:, total_row:)
54+
end
55+
56+
def breadcrumb_title(current_group)
57+
if current_group == group
58+
"#{group} #{I18n.t("group.statistics.memberships.layer_only_suffix")}"
59+
else
60+
current_group.local_hierarchy[1..].map(&:to_s).join(" → ")
61+
end
62+
end
63+
64+
def build_role_rows(current_group)
65+
role_types_in(current_group.id).map do |role_type|
66+
build_role_row(current_group.id, role_type)
67+
end
68+
end
69+
70+
def role_types_in(group_id)
71+
keys = entries_by_group_and_type.keys +
72+
exits_by_group_and_type.keys +
73+
actives_by_group_and_type.keys
74+
75+
keys.filter_map { |id, role_type| role_type if id == group_id }.uniq
76+
end
77+
78+
def build_role_row(group_id, type)
79+
entries = entries_by_group_and_type.fetch([group_id, type], 0)
80+
exits = exits_by_group_and_type.fetch([group_id, type], 0)
81+
net = entries - exits
82+
count = actives_by_group_and_type.fetch([group_id, type], 0)
83+
RoleRow.new(label: type.constantize.label, entries:, exits:, net:, count:)
84+
end
85+
86+
def groups
87+
@groups ||= group.self_and_descendants.where(layer_group_id: layer.id).order(:lft).to_a
88+
end
89+
90+
def entries_by_group_and_type
91+
@entries_by_group_and_type ||= entries_scope.group(:group_id, :type).count
92+
end
93+
94+
def exits_by_group_and_type
95+
@exits_by_group_and_type ||= exits_scope.group(:group_id, :type).count
96+
end
97+
98+
def actives_by_group_and_type
99+
@actives_by_group_and_type ||= actives_scope.group(:group_id, :type).count
100+
end
101+
102+
def entries_scope
103+
::Role.with_inactive.where(start_on: from_date..to_date)
104+
end
105+
106+
def exits_scope
107+
::Role.with_inactive.where(end_on: from_date..to_date)
108+
end
109+
110+
def actives_scope
111+
::Role.active(to_date)
112+
end
113+
end
114+
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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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_count', date: l(statistic.to_date)), value: statistic.total_count }, |
24+
{ label_key: t('.total_entries'), value: signed_number(statistic.total_entries) }, |
25+
{ label_key: t('.total_exits'), value: signed_number(-statistic.total_exits) }, |
26+
{ label_key: t('.net_change'), |
27+
value: "#{signed_number(statistic.net_change)} #{net_change_arrow(statistic.net_change)}" } |
28+
] |
29+
= render partial: 'overview_row', collection: rows, as: :row
30+
31+
- statistic.group_breakdowns.each do |breakdown|
32+
= render_card breakdown.role_rows, title: breakdown.title do |role_rows|
33+
%table.table.table-bordered.text-end
34+
%colgroup
35+
%col{style: "width: 40%"}
36+
%col{style: "width: 15%"}
37+
%col{style: "width: 15%"}
38+
%col{style: "width: 15%"}
39+
%col{style: "width: 15%"}
40+
%thead
41+
%tr
42+
%th.text-start= t('.role_type')
43+
%th= t('.count')
44+
%th= t('.entries')
45+
%th= t('.exits')
46+
%th= t('.net')
47+
%tbody
48+
- role_rows.each do |row|
49+
%tr
50+
%td.text-start= row.label
51+
%td= row.count
52+
%td= row.entries
53+
%td= row.exits
54+
%td= "#{signed_number(row.net)} #{net_change_arrow(row.net)}"
55+
%tr.fw-bold
56+
%td.text-start= t('.total')
57+
%td= breakdown.total_row.count
58+
%td= breakdown.total_row.entries
59+
%td= breakdown.total_row.exits
60+
%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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ 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+
total_count: "Anzahl Rollen per %{date}"
102+
role_type: Rollentyp
103+
entries: Eintritte
104+
exits: Austritte
105+
net: Netto
106+
count: Anzahl
107+
total: Total
108+
layer_only_suffix: "(nur Hauptgruppe ohne Untergruppen)"
92109

93110
groups:
94111
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)