diff --git a/app/Group.php b/app/Group.php
index 1a2dbebb32..65421a9f9f 100644
--- a/app/Group.php
+++ b/app/Group.php
@@ -325,7 +325,12 @@ public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio =
->with('allDevices')
->withCount('allInvited')
->whereIn('events.group', $groupIds)
- ->lazy(200)
+ // lazyById pages on the primary key (WHERE idevents > last) rather than lazy()'s growing
+ // OFFSET, which made MySQL rescan every earlier row for each chunk on large networks. past()
+ // orders by date, and keyset paging needs the id to be the only sort key or events are skipped
+ // and double-counted - hence reorder().
+ ->reorder()
+ ->lazyById(200, 'events.idevents', 'idevents')
->each(function ($event) use (&$statsByGroup, $eEmissionRatio, $uEmissionratio) {
$gid = $event->group;
if (! isset($statsByGroup[$gid])) {
diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php
index 00d7a6076a..e65cf769a6 100644
--- a/app/Http/Controllers/API/NetworkController.php
+++ b/app/Http/Controllers/API/NetworkController.php
@@ -57,6 +57,9 @@ private function statsForTag(Network $network, int $tagId): array
}
}
+ // Same shape as Network::stats(): the group tally excludes archived groups.
+ $stats['groups'] = $groups->whereNull('archived_at')->count();
+
return $stats;
}
diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php
index ef269b8022..4fa561a355 100644
--- a/app/Http/Controllers/NetworkController.php
+++ b/app/Http/Controllers/NetworkController.php
@@ -55,12 +55,12 @@ public function show(Network $network): View
$groupsForAssociating = [];
if ($user->can('associateGroups', $network)) {
- $groupsForAssociating = $network->groupsNotIn()->sortBy('name');
+ $groupsForAssociating = $network->groupsNotIn();
}
- // Get network stats
- $stats = $network->stats();
- $stats['groups'] = $network->groups->count();
+ // Stats are fetched by the page from the API after it renders. Calculating them walks every device
+ // of every past event in the network, which is too slow to block the page load on for large networks.
+ $stats = [];
// Determine if user can manage tags (NC for this network or Admin)
$canManageTags = Fixometer::hasRole($user, 'Administrator') ||
diff --git a/app/Network.php b/app/Network.php
index eb4069c18a..c0ea3840a1 100644
--- a/app/Network.php
+++ b/app/Network.php
@@ -88,13 +88,17 @@ public function sizedLogo($size)
public function groupsNotIn()
{
- $networkGroupsIds = $this->groups()->pluck('idgroups')->toArray();
+ $networkGroupsIds = $this->groups()->withoutGlobalScopes()->without(['networks', 'group_tags'])->pluck('groups.idgroups')->toArray();
- if (empty($networkGroupsIds)) {
- return Group::orderBy('name')->get();
+ // Only the id and name are needed (for the "add groups" picker). Skip the member-count global scopes
+ // and the networks/tags eager loads, which are expensive across every group on the platform.
+ $query = Group::withoutGlobalScopes()->without(['networks', 'group_tags'])->select(['idgroups', 'name'])->orderBy('name');
+
+ if (! empty($networkGroupsIds)) {
+ $query->whereNotIn('idgroups', $networkGroupsIds);
}
- return Group::whereNotIn('idgroups', $networkGroupsIds)->orderBy('name')->get();
+ return $query->get();
}
public function stats()
@@ -102,7 +106,11 @@ public function stats()
$eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered();
$uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered();
- $allStats = \App\Group::bulkGroupStats($this->groups, $eEmissionRatio, $uEmissionratio);
+ // Impact stats include archived groups (their past events still count); the group tally does not.
+ $groups = $this->groups()->withoutGlobalScopes()->without(['networks', 'group_tags'])
+ ->get(['groups.idgroups', 'groups.archived_at']);
+
+ $allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio);
$stats = \App\Group::getGroupStatsArrayKeys();
@@ -113,6 +121,7 @@ public function stats()
}
$stats['parties'] = $stats['parties'] ?? 0;
+ $stats['groups'] = $groups->whereNull('archived_at')->count();
return $stats;
}
diff --git a/resources/js/components/NetworkPage.vue b/resources/js/components/NetworkPage.vue
index 59cb4264eb..c87cfc943d 100644
--- a/resources/js/components/NetworkPage.vue
+++ b/resources/js/components/NetworkPage.vue
@@ -23,19 +23,19 @@
{{ __('networks.general.impact') }}
-
{{ stats.groups || 0 }}
+
{{ statsLoaded ? (stats.groups || 0) : statsPlaceholder }}
{{ __('networks.stats.groups', { count: stats.groups || 0 }) }}
-
{{ stats.parties || 0 }}
+
{{ statsLoaded ? (stats.parties || 0) : statsPlaceholder }}
{{ __('networks.stats.events', { count: stats.parties || 0 }) }}
-
{{ formatWeight(stats.waste_total) }}
+
{{ statsLoaded ? formatWeight(stats.waste_total) : statsPlaceholder }}
{{ __('networks.stats.waste_diverted') }}
-
{{ formatWeight(stats.co2_total) }}
+
{{ statsLoaded ? formatWeight(stats.co2_total) : statsPlaceholder }}
{{ __('networks.stats.co2_prevented') }}
@@ -219,6 +219,7 @@ export default {
data() {
return {
stats: this.initialStats,
+ statsFailed: false,
tags: this.initialTags,
newTagName: '',
newTagDescription: '',
@@ -234,6 +235,13 @@ export default {
}
},
computed: {
+ statsLoaded() {
+ return !!this.stats && Object.keys(this.stats).length > 0
+ },
+ statsPlaceholder() {
+ // Loading, or a dash if the stats request failed rather than an ellipsis forever.
+ return this.statsFailed ? '–' : '…'
+ },
worldBounds() {
// The inverted whole-world box: GroupMap treats it as "no location", so
// it frames all the (network-filtered) groups instead.
@@ -363,6 +371,7 @@ export default {
this.stats = response.data
} catch (error) {
console.error('Failed to fetch network stats:', error)
+ this.statsFailed = true
}
}
diff --git a/tests/Feature/Networks/APIv2NetworkTest.php b/tests/Feature/Networks/APIv2NetworkTest.php
index 326d949171..34ea412bfe 100644
--- a/tests/Feature/Networks/APIv2NetworkTest.php
+++ b/tests/Feature/Networks/APIv2NetworkTest.php
@@ -873,6 +873,7 @@ public function testGetNetworkStatsFilteredByTag(): void {
$this->assertArrayHasKey('parties', $json);
$this->assertArrayHasKey('co2_total', $json);
$this->assertArrayHasKey('waste_total', $json);
+ $this->assertEquals(1, $json['groups']);
}
/**
diff --git a/tests/Feature/Networks/NetworkTest.php b/tests/Feature/Networks/NetworkTest.php
index b256b88299..bc1a36929e 100644
--- a/tests/Feature/Networks/NetworkTest.php
+++ b/tests/Feature/Networks/NetworkTest.php
@@ -231,6 +231,7 @@ public function network_stats_can_be_queried(): void
$response = $this->get("/api/networks/{$network->id}/stats?api_token=1234");
$stats = json_decode($response->getContent(), true);
$expectedStats = \App\Group::getGroupStatsArrayKeys();
+ $expectedStats['groups'] = 1;
$this->assertEquals($expectedStats, $stats);
}
diff --git a/tests/Feature/Stats/NetworkStatsTest.php b/tests/Feature/Stats/NetworkStatsTest.php
index 6b99a94b9a..64c79fad5b 100644
--- a/tests/Feature/Stats/NetworkStatsTest.php
+++ b/tests/Feature/Stats/NetworkStatsTest.php
@@ -8,6 +8,7 @@
use App\Network;
use App\Party;
use App\User;
+use Carbon\Carbon;
use Tests\Feature\Stats\StatsTestCase;
/**
@@ -33,10 +34,61 @@ public function a_network_with_no_groups_has_empty_stats(): void
$expect = \App\Group::getGroupStatsArrayKeys();
$expect['parties'] = $expect['parties'] ?? 0;
+ $expect['groups'] = 0;
$this->assertEquals($expect, $network->stats());
}
+ /** @test */
+ public function network_stats_count_every_event_once_across_chunks(): void
+ {
+ // More events than one lazyById chunk (200), with dates running opposite to ids, so any ordering other
+ // than by id would make chunks overlap or skip events.
+ $network = Network::factory()->create();
+ $group1 = Group::factory()->create();
+ $group2 = Group::factory()->create();
+ $network->addGroup($group1);
+ $network->addGroup($group2);
+
+ $count = 450;
+ $expectedVolunteers = 0;
+ for ($i = 1; $i <= $count; $i++) {
+ $start = Carbon::parse('2020-01-01 10:00:00')->addDays($i);
+ Party::factory()->moderated()->create([
+ 'group' => $i % 2 ? $group1->idgroups : $group2->idgroups,
+ 'event_start_utc' => $start->toIso8601String(),
+ 'event_end_utc' => $start->copy()->addHours(3)->toIso8601String(),
+ 'volunteers' => $i,
+ ]);
+ $expectedVolunteers += $i;
+ }
+
+ $stats = $network->stats();
+ $this->assertEquals($count, $stats['parties']);
+ $this->assertEquals($expectedVolunteers, $stats['volunteers']);
+ }
+
+ /** @test */
+ public function network_group_count_excludes_archived_groups_but_impact_includes_them(): void
+ {
+ $network = Network::factory()->create();
+
+ $active = Group::factory()->create();
+ $archived = Group::factory()->create(['archived_at' => '2024-01-01 00:00:00']);
+ $network->addGroup($active);
+ $network->addGroup($archived);
+
+ Party::factory()->moderated()->create([
+ 'event_start_utc' => '2000-01-01T10:00:00+00:00',
+ 'event_end_utc' => '2000-01-01T13:00:00+00:00',
+ 'group' => $archived->idgroups,
+ ]);
+
+ $stats = $network->stats();
+ $this->assertEquals(1, $stats['groups']);
+ $this->assertEquals(1, $stats['parties']);
+ }
+
/** @test */
public function network_stats_aggregate_misc_estimates_and_footprints_across_groups(): void
{
diff --git a/tests/Feature/Stats/StatsTestCase.php b/tests/Feature/Stats/StatsTestCase.php
index 5b2b939a68..1a25c835ae 100644
--- a/tests/Feature/Stats/StatsTestCase.php
+++ b/tests/Feature/Stats/StatsTestCase.php
@@ -13,6 +13,7 @@ class StatsTestCase extends TestCase
{
protected $_displacementFactor;
protected $_ratioUnpowered;
+ protected $_ratioPowered;
protected $_idUnpoweredMisc;
protected $_idPoweredMisc;
protected $_idUnpoweredNonMisc;