From 30dfa213feb5ab5fbd2903d30d99e101d76bc2fa Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:01:50 +0000 Subject: [PATCH 1/5] Network events API: overlap window, cancelled/approved flags, coordinator include_unapproved; stats period Adds the events feed changes from the network events thought experiment (RESDISCO-248) and the last-12-completed-months stats period. Co-Authored-By: Claude Opus 5.5 --- app/Group.php | 6 +- app/Helpers/StatsPeriod.php | 39 +++++ .../Controllers/API/NetworkController.php | 55 +++++-- app/Http/Resources/GroupSummary.php | 7 + app/Http/Resources/PartySummary.php | 8 + app/Network.php | 6 +- docs/specs/manifest.json | 74 +++++++-- lang/en/networks.php | 30 ++++ lang/fr-BE/networks.php | 30 ++++ lang/fr/networks.php | 30 ++++ tests/Feature/Networks/APIv2NetworkTest.php | 147 +++++++++++++++++- tests/Unit/StatsPeriodTest.php | 27 ++++ 12 files changed, 435 insertions(+), 24 deletions(-) create mode 100644 app/Helpers/StatsPeriod.php create mode 100644 tests/Unit/StatsPeriodTest.php diff --git a/app/Group.php b/app/Group.php index 03852c4b53..8151cc6a81 100644 --- a/app/Group.php +++ b/app/Group.php @@ -315,8 +315,10 @@ public function getGroupStats($eEmissionRatio = null, $uEmissionratio = null) /** * Compute stats for multiple groups in O(1) queries instead of O(N). * Returns a map of [groupId => stats array]. + * + * $from and $to (UTC, $to exclusive) limit the stats to events starting in that window. */ - public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio = null, ?float $uEmissionratio = null): array + public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio = null, ?float $uEmissionratio = null, ?\Carbon\Carbon $from = null, ?\Carbon\Carbon $to = null): array { $groups = collect($groups); if ($groups->isEmpty()) { @@ -351,6 +353,8 @@ public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio = ->with('allDevices') ->withCount('allInvited') ->whereIn('events.group', $groupIds) + ->when($from, fn ($q) => $q->where('event_start_utc', '>=', $from->toIso8601String())) + ->when($to, fn ($q) => $q->where('event_start_utc', '<', $to->toIso8601String())) // 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 diff --git a/app/Helpers/StatsPeriod.php b/app/Helpers/StatsPeriod.php new file mode 100644 index 0000000000..0a9b2a819b --- /dev/null +++ b/app/Helpers/StatsPeriod.php @@ -0,0 +1,39 @@ +copy()->setTimezone('UTC')->startOfMonth(); + + return [$to->copy()->subMonths(12), $to]; + } + + /** + * The first and last days covered by a window, for display. + */ + public static function describe(Carbon $from, Carbon $to): array + { + return ['start' => $from->toDateString(), 'end' => $to->copy()->subDay()->toDateString()]; + } +} diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php index 4a022c5ba3..f3a226582a 100644 --- a/app/Http/Controllers/API/NetworkController.php +++ b/app/Http/Controllers/API/NetworkController.php @@ -11,6 +11,7 @@ use App\Network; use App\Party; use Auth; +use App\Helpers\StatsPeriod; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -40,7 +41,7 @@ public function stats(Request $request, Network $network): JsonResponse /** * Get stats for groups in a network that have a specific tag. */ - private function statsForTag(Network $network, int $tagId): array + private function statsForTag(Network $network, int $tagId, ?Carbon $from = null, ?Carbon $to = null): array { $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); $uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered(); @@ -51,7 +52,7 @@ private function statsForTag(Network $network, int $tagId): array ->where('grouptags_groups.group_tag', $tagId) ->get(); - $allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio); + $allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio, $from, $to); $stats = \App\Group::getGroupStatsArrayKeys(); @@ -386,9 +387,16 @@ public function getNetworkGroupsv2(Request $request, $id) #[UserStory('As a Guest, I can list events for a network via the API', persona: 'Guest', theme: 'Network groups & events')] #[UserStory('As a ThirdParty, I can retrieve events for a network to display on my platform', persona: 'ThirdParty', theme: 'Network groups & events')] + #[UserStory('As a NetworkCoordinator, I can also see events from unapproved and archived groups in my network', persona: 'NetworkCoordinator', theme: 'Network groups & events')] public function getNetworkEventsv2(Request $request, $id) { - Network::findOrFail($id); + $network = Network::findOrFail($id); + + // Coordinators of this network (and admins) can ask for events on unapproved and archived groups too, so + // that they can spot a group waiting for approval which already has events, or look back at a closed one. + $user = auth('api')->user(); + $includeUnapproved = $request->boolean('include_unapproved') && $user && + ($user->hasRole('Administrator') || $user->isCoordinatorOf($network)); // Get date filters. We default to far past and far future so that we don't need multiple code branches. We // don't need to validate the date format - if they put junk in then they'll get junk matches back. @@ -415,12 +423,15 @@ public function getNetworkEventsv2(Request $request, $id) ->with('allDevices', 'theGroup.networks', 'theGroup.groupImage.image') ->withCount('allInvited') ->where('group_network.network_id', $id) - ->where('event_start_utc', '>=', $start) - ->where('event_end_utc', '<=', $end) + ->where('event_start_utc', '<=', $end) + ->where('event_end_utc', '>=', $start) ->where('events.updated_at', '>=', $updated_start) ->where('events.updated_at', '<=', $updated_end) - ->where('events.approved', true) - ->where('groups.approved', true); + ->where('events.approved', true); + + if (!$includeUnapproved) { + $query = $query->where('groups.approved', true)->whereNull('groups.archived_at'); + } // Filter by group tag if specified if ($request->has('group_tag')) { @@ -524,10 +535,24 @@ public function getNetworkTagsv2(Request $request, $id) * type="integer" * ) * ), + * @OA\Parameter( + * name="period", + * description="all (default) for all time, or last_12_months for events in the last 12 completed calendar months. The group count is always the current one.", + * required=false, + * in="query", + * @OA\Schema( + * type="string", + * enum={"all", "last_12_months"} + * ) + * ), * @OA\Response( * response=200, * description="Successful operation", * @OA\JsonContent( + * @OA\Property(property="period", type="object", description="Only for last_12_months: the first and last days covered.", + * @OA\Property(property="start", type="string", example="2025-10-01"), + * @OA\Property(property="end", type="string", example="2026-09-30") + * ), * @OA\Property(property="co2_total", type="number", example=17631.6), * @OA\Property(property="co2_powered", type="number", example=17475.97), * @OA\Property(property="co2_unpowered", type="number", example=155.64), @@ -556,16 +581,24 @@ public function getNetworkTagsv2(Request $request, $id) */ #[UserStory('As a Guest, I can view a network\'s impact statistics via the API, optionally for one group tag', persona: 'Guest', theme: 'Network stats')] #[UserStory('As a ThirdParty, I can retrieve a network\'s impact statistics to display on my platform', persona: 'ThirdParty', theme: 'Network stats')] + #[UserStory('As a Host, I can see my network\'s impact over the last 12 completed months as well as all time', persona: 'Host', theme: 'Network stats')] public function getNetworkStatsv2(Request $request, $id): JsonResponse { $network = Network::findOrFail($id); - if ($request->has('group_tag')) { - $tagId = $request->get('group_tag'); - return response()->json($this->statsForTag($network, $tagId)); + $request->validate(['period' => 'nullable|in:' . StatsPeriod::ALL . ',' . StatsPeriod::LAST_12_MONTHS]); + + [$from, $to] = StatsPeriod::window($request->get('period')); + + $stats = $request->has('group_tag') + ? $this->statsForTag($network, $request->get('group_tag'), $from, $to) + : $network->stats($from, $to); + + if ($from) { + $stats['period'] = StatsPeriod::describe($from, $to); } - return response()->json($network->stats()); + return response()->json($stats); } /** diff --git a/app/Http/Resources/GroupSummary.php b/app/Http/Resources/GroupSummary.php index cacd66e331..4f3f54c8ae 100644 --- a/app/Http/Resources/GroupSummary.php +++ b/app/Http/Resources/GroupSummary.php @@ -99,6 +99,12 @@ * description="If present, this group has been archived and is no longer active.", * format="date-time", * ), + * @OA\Property( + * property="approved", + * title="approved", + * description="Whether this group has been approved.", + * type="boolean", + * ), * ) */ @@ -129,6 +135,7 @@ public function toArray(Request $request): array }), 'updated_at' => Carbon::parse($this->updated_at)->toIso8601String(), 'archived_at' => $this->archived_at ? Carbon::parse($this->archived_at)->toIso8601String() : null, + 'approved' => (bool) $this->approved, 'summary' => true ]; diff --git a/app/Http/Resources/PartySummary.php b/app/Http/Resources/PartySummary.php index 3fa5cb2d56..8e5e69c993 100644 --- a/app/Http/Resources/PartySummary.php +++ b/app/Http/Resources/PartySummary.php @@ -62,6 +62,13 @@ * example="false" * ), * @OA\Property( + * property="cancelled", + * title="cancelled", + * description="Whether this event has been cancelled.", + * format="boolean", + * example="false" + * ), + * @OA\Property( * property="lat", * title="lat", * description="Latitude at which the event is taking place. Only valid if online=false.", @@ -131,6 +138,7 @@ public function toArray(Request $request): array 'title' => $this->venue ?? $this->location, 'location' => $this->location, 'online' => $this->online, + 'cancelled' => $this->cancelled ? true : false, 'lat' => $this->latitude, 'lng' => $this->longitude, 'group' => \App\Http\Resources\GroupSummary::make($this->theGroup), diff --git a/app/Network.php b/app/Network.php index c0ea3840a1..da4e11ea96 100644 --- a/app/Network.php +++ b/app/Network.php @@ -101,7 +101,9 @@ public function groupsNotIn() return $query->get(); } - public function stats() + // $from and $to (UTC, $to exclusive) limit the impact figures to events in that window. The group tally is + // always the current one. + public function stats(?\Carbon\Carbon $from = null, ?\Carbon\Carbon $to = null) { $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); $uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered(); @@ -110,7 +112,7 @@ public function stats() $groups = $this->groups()->withoutGlobalScopes()->without(['networks', 'group_tags']) ->get(['groups.idgroups', 'groups.archived_at']); - $allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio); + $allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio, $from, $to); $stats = \App\Group::getGroupStatsArrayKeys(); diff --git a/docs/specs/manifest.json b/docs/specs/manifest.json index a3e38ffc5f..968d300120 100644 --- a/docs/specs/manifest.json +++ b/docs/specs/manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-23T17:48:00Z", + "generatedAt": "2026-10-27T17:01:43Z", "features": { "Administration": { "description": "Platform administration and configuration", @@ -2317,6 +2317,10 @@ { "file": "tests/Feature/Networks/APIv2NetworkTest.php", "test": "testListEvents" + }, + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkEventsIncludeUnapprovedForCoordinators" } ] }, @@ -2336,7 +2340,12 @@ "method": "NetworkController::getNetworkStatsv2", "class": "App\\Http\\Controllers\\API\\NetworkController", "file": "app/Http/Controllers/API/NetworkController.php", - "tests": [] + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] }, { "story": "As a Guest, I can list a network's group tags via the API", @@ -2379,6 +2388,20 @@ } ] }, + { + "story": "As a Host, I can see my network's impact over the last 12 completed months as well as all time", + "persona": "Host", + "theme": "Network stats", + "method": "NetworkController::getNetworkStatsv2", + "class": "App\\Http\\Controllers\\API\\NetworkController", + "file": "app/Http/Controllers/API/NetworkController.php", + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] + }, { "story": "As a NetworkCoordinator, I can add groups to my network", "persona": "NetworkCoordinator", @@ -2419,6 +2442,28 @@ "file": "app/Http/Controllers/API/NetworkController.php", "tests": [] }, + { + "story": "As a NetworkCoordinator, I can also see events from unapproved and archived groups in my network", + "persona": "NetworkCoordinator", + "theme": "Network groups & events", + "method": "NetworkController::getNetworkEventsv2", + "class": "App\\Http\\Controllers\\API\\NetworkController", + "file": "app/Http/Controllers/API/NetworkController.php", + "tests": [ + { + "file": "tests/Feature/Events/APIv2EventTest.php", + "test": "testGetEventsForUnapprovedGroup" + }, + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testListEvents" + }, + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkEventsIncludeUnapprovedForCoordinators" + } + ] + }, { "story": "As a NetworkCoordinator, I can view my network's statistics via the API", "persona": "NetworkCoordinator", @@ -2480,6 +2525,10 @@ { "file": "tests/Feature/Networks/APIv2NetworkTest.php", "test": "testListEvents" + }, + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkEventsIncludeUnapprovedForCoordinators" } ] }, @@ -2499,7 +2548,12 @@ "method": "NetworkController::getNetworkStatsv2", "class": "App\\Http\\Controllers\\API\\NetworkController", "file": "app/Http/Controllers/API/NetworkController.php", - "tests": [] + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] }, { "story": "As a ThirdParty, I can retrieve a network's group tags to display on my platform", @@ -2543,10 +2597,11 @@ ] } ], - "storyCount": 22, + "storyCount": 24, "personas": [ "Admin", "Guest", + "Host", "NetworkCoordinator", "ThirdParty" ] @@ -3671,9 +3726,10 @@ "features": [ "Devices", "Events", - "Groups" + "Groups", + "Networks" ], - "storyCount": 33 + "storyCount": 34 }, "NetworkCoordinator": { "features": [ @@ -3681,7 +3737,7 @@ "Groups", "Networks" ], - "storyCount": 12 + "storyCount": 13 }, "RepairDirectoryAdmin": { "features": [ @@ -3713,7 +3769,7 @@ } }, "coverage": { - "annotatedStories": 256, - "storiesWithTests": 107 + "annotatedStories": 258, + "storiesWithTests": 111 } } diff --git a/lang/en/networks.php b/lang/en/networks.php index 6b4085ce37..f4e6ef0e37 100644 --- a/lang/en/networks.php +++ b/lang/en/networks.php @@ -16,6 +16,10 @@ 'impact' => 'Impact' ], 'stats' => [ + 'all_time' => 'All time', + 'last_12_months' => 'Last 12 months', + 'period_toggle' => 'Period', + 'period_range' => ':start to :end', 'groups' => '{1} Group|[0,*] Groups', 'events' => '{1} Event|[0,*] Events', 'waste_diverted' => 'Waste Diverted', @@ -77,4 +81,30 @@ 'new_field_name' => 'New field name', 'add_field' => 'Add field', ], + 'events' => [ + 'title' => 'Upcoming events', + 'list' => 'List', + 'calendar' => 'Calendar', + 'subscribe' => 'Subscribe to calendar', + 'groups' => 'Groups', + 'all_groups' => 'All groups', + 'range_7' => 'Next 7 days', + 'range_30' => 'Next 30 days', + 'range_custom' => 'Custom range', + 'from' => 'From', + 'to' => 'To', + 'include_past' => 'Include past events', + 'clear' => 'Clear filters', + 'count' => '{0} No events|{1} :count event|[2,*] :count events', + 'empty' => 'No events match these filters.', + 'online' => 'Online', + 'cancelled' => 'Cancelled', + 'group_unapproved' => 'Group not approved', + 'group_archived' => 'Archived group', + 'prev_month' => 'Previous month', + 'next_month' => 'Next month', + 'loading' => 'Loading events...', + 'error' => 'Could not load events.', + 'view_toggle' => 'Events view', + ], ]; diff --git a/lang/fr-BE/networks.php b/lang/fr-BE/networks.php index 06db741a0d..642dce4cc1 100644 --- a/lang/fr-BE/networks.php +++ b/lang/fr-BE/networks.php @@ -24,6 +24,10 @@ 'impact' => 'Impact', ], 'stats' => [ + 'all_time' => 'Depuis le début', + 'last_12_months' => '12 derniers mois', + 'period_toggle' => 'Période', + 'period_range' => 'De :start à :end', 'groups' => '{1} Repair Café|[0,*] Repair Cafés', 'events' => '{1} Événement|[0,*] Événements', 'waste_diverted' => 'Déchets évités', @@ -71,4 +75,30 @@ 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', ], + 'events' => [ + 'title' => 'Événements à venir', + 'list' => 'Liste', + 'calendar' => 'Calendrier', + 'subscribe' => 'S\'abonner au calendrier', + 'groups' => 'Repair cafés', + 'all_groups' => 'Tous les repair cafés', + 'range_7' => '7 prochains jours', + 'range_30' => '30 prochains jours', + 'range_custom' => 'Période personnalisée', + 'from' => 'Du', + 'to' => 'Au', + 'include_past' => 'Inclure les événements passés', + 'clear' => 'Effacer les filtres', + 'count' => '{0} Aucun événement|{1} :count événement|[2,*] :count événements', + 'empty' => 'Aucun événement ne correspond à ces filtres.', + 'online' => 'En ligne', + 'cancelled' => 'Annulé', + 'group_unapproved' => 'Repair café non approuvé', + 'group_archived' => 'Repair café archivé', + 'prev_month' => 'Mois précédent', + 'next_month' => 'Mois suivant', + 'loading' => 'Chargement des événements...', + 'error' => 'Impossible de charger les événements.', + 'view_toggle' => 'Affichage des événements', + ], ]; diff --git a/lang/fr/networks.php b/lang/fr/networks.php index 06db741a0d..642dce4cc1 100644 --- a/lang/fr/networks.php +++ b/lang/fr/networks.php @@ -24,6 +24,10 @@ 'impact' => 'Impact', ], 'stats' => [ + 'all_time' => 'Depuis le début', + 'last_12_months' => '12 derniers mois', + 'period_toggle' => 'Période', + 'period_range' => 'De :start à :end', 'groups' => '{1} Repair Café|[0,*] Repair Cafés', 'events' => '{1} Événement|[0,*] Événements', 'waste_diverted' => 'Déchets évités', @@ -71,4 +75,30 @@ 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', ], + 'events' => [ + 'title' => 'Événements à venir', + 'list' => 'Liste', + 'calendar' => 'Calendrier', + 'subscribe' => 'S\'abonner au calendrier', + 'groups' => 'Repair cafés', + 'all_groups' => 'Tous les repair cafés', + 'range_7' => '7 prochains jours', + 'range_30' => '30 prochains jours', + 'range_custom' => 'Période personnalisée', + 'from' => 'Du', + 'to' => 'Au', + 'include_past' => 'Inclure les événements passés', + 'clear' => 'Effacer les filtres', + 'count' => '{0} Aucun événement|{1} :count événement|[2,*] :count événements', + 'empty' => 'Aucun événement ne correspond à ces filtres.', + 'online' => 'En ligne', + 'cancelled' => 'Annulé', + 'group_unapproved' => 'Repair café non approuvé', + 'group_archived' => 'Repair café archivé', + 'prev_month' => 'Mois précédent', + 'next_month' => 'Mois suivant', + 'loading' => 'Chargement des événements...', + 'error' => 'Impossible de charger les événements.', + 'view_toggle' => 'Affichage des événements', + ], ]; diff --git a/tests/Feature/Networks/APIv2NetworkTest.php b/tests/Feature/Networks/APIv2NetworkTest.php index 5a45682770..19af5f4351 100644 --- a/tests/Feature/Networks/APIv2NetworkTest.php +++ b/tests/Feature/Networks/APIv2NetworkTest.php @@ -15,6 +15,8 @@ class APIv2NetworkTest extends TestCase { + private const END_PARAM = '&end='; + /** @story:NetworkController::getNetworksv2 */ public function testList(): void { $user = User::factory()->administrator()->create([ @@ -1027,4 +1029,147 @@ public function testUnauthenticatedGroupTagsReturnsEmpty(): void { // Should return empty array $this->assertEmpty($json); } -} \ No newline at end of file + + public function testNetworkEventsOverlapArchivedAndCancelled(): void { + $network = Network::factory()->create(); + $group = Group::factory()->create(['approved' => true]); + $archived = Group::factory()->create(['approved' => true, 'archived_at' => '2020-01-01']); + $network->addGroup($group); + $network->addGroup($archived); + + // Event spanning midnight between 1st and 2nd Jan 2038. + $spanning = Party::factory()->moderated()->create([ + 'group' => $group->idgroups, + 'event_start_utc' => '2038-01-01T23:00:00Z', + 'event_end_utc' => '2038-01-02T02:00:00Z', + ]); + $cancelled = Party::factory()->moderated()->create([ + 'group' => $group->idgroups, + 'event_start_utc' => '2038-01-05T10:00:00Z', + 'event_end_utc' => '2038-01-05T12:00:00Z', + ]); + DB::table('events')->where('idevents', $cancelled->idevents)->update(['cancelled' => 1]); + Party::factory()->moderated()->create([ + 'group' => $archived->idgroups, + 'event_start_utc' => '2038-01-02T10:00:00Z', + 'event_end_utc' => '2038-01-02T12:00:00Z', + ]); + + // A window which only touches the end of the spanning event returns it (overlap, not containment). + $response = $this->get("/api/v2/networks/{$network->id}/events?start=" . urlencode('2038-01-02T00:00:00Z') . + self::END_PARAM . urlencode('2038-01-03T00:00:00Z')); + $response->assertSuccessful(); + $json = json_decode($response->getContent(), true)['data']; + $this->assertCount(1, $json, 'Archived group event must be excluded'); + $this->assertEquals($spanning->idevents, $json[0]['id']); + $this->assertFalse($json[0]['cancelled']); + + // Window after the spanning event does not return it. + $response = $this->get("/api/v2/networks/{$network->id}/events?start=" . urlencode('2038-01-03T00:00:00Z') . + self::END_PARAM . urlencode('2038-01-04T00:00:00Z')); + $this->assertCount(0, json_decode($response->getContent(), true)['data']); + + // Cancelled events are returned, flagged. + $response = $this->get("/api/v2/networks/{$network->id}/events?start=" . urlencode('2038-01-05T00:00:00Z') . + self::END_PARAM . urlencode('2038-01-06T00:00:00Z')); + $json = json_decode($response->getContent(), true)['data']; + $this->assertCount(1, $json); + $this->assertTrue($json[0]['cancelled']); + } + + /** + * Coordinators can also see events from unapproved and archived groups in their network. Nobody else can. + * + * @story:NetworkController::getNetworkEventsv2 + */ + public function testNetworkEventsIncludeUnapprovedForCoordinators(): void { + $network = Network::factory()->create(); + $other = Network::factory()->create(); + $approved = Group::factory()->create(['approved' => true]); + $unapproved = Group::factory()->create(['approved' => false]); + $archived = Group::factory()->create(['approved' => true, 'archived_at' => '2020-01-01']); + + foreach ([$approved, $unapproved, $archived] as $g) { + $network->addGroup($g); + Party::factory()->moderated()->create([ + 'group' => $g->idgroups, + 'event_start_utc' => '2038-02-01T10:00:00Z', + 'event_end_utc' => '2038-02-01T12:00:00Z', + ]); + } + + $coordinator = User::factory()->networkCoordinator()->create(['api_token' => 'coord-token']); + $network->addCoordinator($coordinator); + $otherCoordinator = User::factory()->networkCoordinator()->create(['api_token' => 'other-coord-token']); + $other->addCoordinator($otherCoordinator); + User::factory()->administrator()->create(['api_token' => 'admin-token']); + User::factory()->host()->create(['api_token' => 'host-token']); + User::factory()->restarter()->create(['api_token' => 'volunteer-token']); + + $url = "/api/v2/networks/{$network->id}/events?include_unapproved=1&start=" . + urlencode('2038-02-01T00:00:00Z') . self::END_PARAM . urlencode('2038-02-02T00:00:00Z'); + + $ids = function ($token) use ($url) { + // The token guard caches its user for the life of the app, which spans requests in a test. + $this->app['auth']->forgetGuards(); + $response = $this->get($url . ($token ? '&api_token=' . $token : '')); + $response->assertSuccessful(); + + return collect(json_decode($response->getContent(), true)['data'])->pluck('group.id')->sort()->values()->all(); + }; + + // The parameter is ignored for anyone who isn't a coordinator of this network or an admin. + $this->assertEquals([$approved->idgroups], $ids(null)); + $this->assertEquals([$approved->idgroups], $ids('host-token')); + $this->assertEquals([$approved->idgroups], $ids('volunteer-token')); + $this->assertEquals([$approved->idgroups], $ids('other-coord-token')); + + $all = collect([$approved, $unapproved, $archived])->pluck('idgroups')->sort()->values()->all(); + $this->assertEquals($all, $ids('coord-token')); + $this->assertEquals($all, $ids('admin-token')); + + // The group's approval and archive state come back so the page can badge them. + $this->app['auth']->forgetGuards(); + $response = $this->get($url . '&api_token=coord-token'); + $byGroup = collect(json_decode($response->getContent(), true)['data'])->keyBy('group.id'); + $this->assertTrue($byGroup[$approved->idgroups]['group']['approved']); + $this->assertFalse($byGroup[$unapproved->idgroups]['group']['approved']); + $this->assertNotNull($byGroup[$archived->idgroups]['group']['archived_at']); + } + + /** + * The headline stats can be for the last 12 completed months instead of all time. + * + * @story:NetworkController::getNetworkStatsv2 + */ + public function testNetworkStatsForLastTwelveCompletedMonths(): void { + Carbon::setTestNow('2026-10-23 12:00:00'); + + $network = Network::factory()->create(); + $group = Group::factory()->create(['approved' => true]); + $network->addGroup($group); + + // The window is 1 Oct 2025 to 30 Sep 2026: the first and last events fall outside it. + foreach (['2025-09-15', '2025-10-01', '2026-09-30', '2026-10-05'] as $day) { + Party::factory()->moderated()->create([ + 'group' => $group->idgroups, + 'event_start_utc' => $day . 'T10:00:00Z', + 'event_end_utc' => $day . 'T12:00:00Z', + ]); + } + + $all = $this->get("/api/v2/networks/{$network->id}/stats")->assertSuccessful()->json(); + $this->assertEquals(4, $all['parties']); + + $recent = $this->get("/api/v2/networks/{$network->id}/stats?period=last_12_months")->assertSuccessful()->json(); + $this->assertEquals(2, $recent['parties']); + $this->assertEquals(1, $recent['groups'], 'The group tally is not limited to the period'); + $this->assertEquals('2025-10-01', $recent['period']['start']); + $this->assertEquals('2026-09-30', $recent['period']['end']); + + Carbon::setTestNow(); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + $this->getJson("/api/v2/networks/{$network->id}/stats?period=nonsense"); + } +} diff --git a/tests/Unit/StatsPeriodTest.php b/tests/Unit/StatsPeriodTest.php new file mode 100644 index 0000000000..55c2d59e02 --- /dev/null +++ b/tests/Unit/StatsPeriodTest.php @@ -0,0 +1,27 @@ +assertSame([null, null], StatsPeriod::window('all')); + $this->assertSame([null, null], StatsPeriod::window(null)); + } + + public function testLastTwelveMonthsAreCompletedCalendarMonths(): void + { + foreach (['2026-10-01 00:00:00', '2026-10-24 12:00:00', '2026-10-31 23:59:59'] as $now) { + [$from, $to] = StatsPeriod::window('last_12_months', Carbon::parse($now, 'UTC')); + $this->assertEquals(['start' => '2025-10-01', 'end' => '2026-09-30'], StatsPeriod::describe($from, $to)); + } + + [$from, $to] = StatsPeriod::window('last_12_months', Carbon::parse('2026-01-15', 'UTC')); + $this->assertEquals(['start' => '2025-01-01', 'end' => '2025-12-31'], StatsPeriod::describe($from, $to)); + } +} From 4f41aece763340ae71f8e54d96dce31569911dfd Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:07:39 +0000 Subject: [PATCH 2/5] Network events calendar and list, and the stats period toggle, for the Nuxt client Calendar is the default view; at most 5 chips a day with a +N more popover; per-group colours and logos on the chips. Co-Authored-By: Claude Opus 5.5 --- .../components/networks/NetworkEventChip.vue | 119 +++++++ .../app/components/networks/NetworkEvents.vue | 291 ++++++++++++++++++ .../networks/NetworkEventsCalendar.vue | 235 ++++++++++++++ .../components/networks/NetworkEventsList.vue | 114 +++++++ .../app/components/networks/PeriodToggle.vue | 56 ++++ client/app/utils/networkEvents.js | 86 ++++++ client/i18n/locales/en.json | 43 ++- client/i18n/locales/fr-BE.json | 43 ++- client/i18n/locales/fr.json | 43 ++- .../components/networks/NetworkEvents.spec.js | 242 +++++++++++++++ .../networks/NetworkEventsCalendar.spec.js | 225 ++++++++++++++ .../networks/NetworkEventsList.spec.js | 80 +++++ .../components/networks/PeriodToggle.spec.js | 36 +++ client/tests/utils/networkEvents.spec.js | 67 ++++ lang/en/networks.php | 11 +- lang/fr-BE/networks.php | 9 + lang/fr/networks.php | 9 + 17 files changed, 1702 insertions(+), 7 deletions(-) create mode 100644 client/app/components/networks/NetworkEventChip.vue create mode 100644 client/app/components/networks/NetworkEvents.vue create mode 100644 client/app/components/networks/NetworkEventsCalendar.vue create mode 100644 client/app/components/networks/NetworkEventsList.vue create mode 100644 client/app/components/networks/PeriodToggle.vue create mode 100644 client/app/utils/networkEvents.js create mode 100644 client/tests/components/networks/NetworkEvents.spec.js create mode 100644 client/tests/components/networks/NetworkEventsCalendar.spec.js create mode 100644 client/tests/components/networks/NetworkEventsList.spec.js create mode 100644 client/tests/components/networks/PeriodToggle.spec.js create mode 100644 client/tests/utils/networkEvents.spec.js diff --git a/client/app/components/networks/NetworkEventChip.vue b/client/app/components/networks/NetworkEventChip.vue new file mode 100644 index 0000000000..8a970951de --- /dev/null +++ b/client/app/components/networks/NetworkEventChip.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/client/app/components/networks/NetworkEvents.vue b/client/app/components/networks/NetworkEvents.vue new file mode 100644 index 0000000000..92360b1249 --- /dev/null +++ b/client/app/components/networks/NetworkEvents.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/client/app/components/networks/NetworkEventsCalendar.vue b/client/app/components/networks/NetworkEventsCalendar.vue new file mode 100644 index 0000000000..b922747f0d --- /dev/null +++ b/client/app/components/networks/NetworkEventsCalendar.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/client/app/components/networks/NetworkEventsList.vue b/client/app/components/networks/NetworkEventsList.vue new file mode 100644 index 0000000000..b68ac4440c --- /dev/null +++ b/client/app/components/networks/NetworkEventsList.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/client/app/components/networks/PeriodToggle.vue b/client/app/components/networks/PeriodToggle.vue new file mode 100644 index 0000000000..d91542ccb9 --- /dev/null +++ b/client/app/components/networks/PeriodToggle.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/client/app/utils/networkEvents.js b/client/app/utils/networkEvents.js new file mode 100644 index 0000000000..093fd959ad --- /dev/null +++ b/client/app/utils/networkEvents.js @@ -0,0 +1,86 @@ +import moment from 'moment-timezone' + +// Pure helpers for the network events list/calendar. Events are the +// PartySummary objects returned by GET /api/v2/networks/{id}/events. + +// The YYYY-MM-DD on which an event starts, in the event's own timezone (so a +// late evening event in Auckland lands on its local day, not the UTC one). +export function localDay(event) { + const m = event.timezone ? moment.tz(event.start, event.timezone) : moment.utc(event.start) + return m.format('YYYY-MM-DD') +} + +// Group events into { 'YYYY-MM-DD': [events sorted by start] }. +export function bucketByDay(events) { + const buckets = {} + + ;(events || []).forEach((e) => { + const day = localDay(e) + if (!buckets[day]) { + buckets[day] = [] + } + buckets[day].push(e) + }) + + Object.keys(buckets).forEach((day) => { + buckets[day].sort((a, b) => new Date(a.start) - new Date(b.start)) + }) + + return buckets +} + +// Weeks of days for a month ('YYYY-MM'), padded to whole weeks. Each day is +// { date: 'YYYY-MM-DD', inMonth: bool, day: number }. firstDay: 0 = Sunday, +// 1 = Monday. +export function monthGrid(month, firstDay = 1) { + const first = moment.utc(month + '-01', 'YYYY-MM-DD') + const lead = (first.day() - firstDay + 7) % 7 + const weekCount = Math.ceil((lead + first.daysInMonth()) / 7) + const cursor = first.clone().subtract(lead, 'days') + const weeks = [] + + for (let w = 0; w < weekCount; w++) { + const week = [] + + for (let d = 0; d < 7; d++) { + week.push({ + date: cursor.format('YYYY-MM-DD'), + inMonth: cursor.format('YYYY-MM') === month, + day: cursor.date(), + }) + cursor.add(1, 'days') + } + + weeks.push(week) + } + + return weeks +} + +// 'YYYY-MM' shifted by n months. +export function shiftMonth(month, n) { + return moment.utc(month + '-01', 'YYYY-MM-DD').add(n, 'months').format('YYYY-MM') +} + +// Colours for group chips. Every entry has a contrast ratio of at least 4.5:1 +// against white, so white text on the fill, and the colour as text/border on +// a white (dashed) chip, are both readable. +export const GROUP_COLOURS = [ + '#1b6e7a', + '#7b3f98', + '#b0451b', + '#2f6b2f', + '#1f4e9c', + '#a12a4d', + '#6b5a1a', + '#44448f', +] + +// A stable colour per group: the same group always gets the same one, on +// every page load and for every viewer. +export function groupColour(groupId) { + const n = Math.abs(Number.parseInt(groupId, 10) || 0) + // 3 is coprime with the palette size, so multiplying first spreads + // neighbouring ids (groups are often created in a batch) across the palette. + return GROUP_COLOURS[(n * 3) % GROUP_COLOURS.length] +} diff --git a/client/i18n/locales/en.json b/client/i18n/locales/en.json index 1277b2896d..91d7a0e39b 100644 --- a/client/i18n/locales/en.json +++ b/client/i18n/locales/en.json @@ -1141,6 +1141,10 @@ "impact": "Impact" }, "stats": { + "all_time": "All time", + "last_12_months": "Last 12 months", + "period_toggle": "Period", + "period_range": "{start} to {end}", "groups": "Group|Groups", "events": "Event|Events", "waste_diverted": "Waste Diverted", @@ -1166,7 +1170,8 @@ "delete": "Delete", "delete_confirm_title": "Delete Tag", "delete_confirm_message": "Are you sure you want to delete the tag \"{name}\"?", - "delete_warning": "This will remove the tag from {count} group.|This will remove the tag from {count} groups." + "delete_warning": "This will remove the tag from {count} group.|This will remove the tag from {count} groups.", + "add": "Add tag" }, "index": { "your_networks": "Your networks", @@ -1184,7 +1189,11 @@ "add_groups_save_button": "Add", "add_groups_warning_none_selected": "No groups selected.", "add_groups_success": "{number} group added.|{number} groups added.", - "none": "None" + "none": "None", + "coordinators_more": "Show {count} more", + "coordinators_fewer": "Show fewer", + "groups_all": "Show all {count} groups", + "groups_fewer": "Show fewer groups" }, "edit": { "label_reporting_url": "Reporting dashboard address", @@ -1194,6 +1203,36 @@ "add_new_field": "Add new field", "new_field_name": "New field name", "add_field": "Add field" + }, + "events": { + "title": "Upcoming events", + "list": "List", + "calendar": "Calendar", + "subscribe": "Subscribe to calendar", + "groups": "Groups", + "all_groups": "All groups", + "range_7": "Next 7 days", + "range_30": "Next 30 days", + "range_custom": "Custom range", + "from": "From", + "to": "To", + "include_past": "Include past events", + "clear": "Clear filters", + "count": "No events|{count} event|{count} events", + "empty": "No events match these filters.", + "online": "Online", + "cancelled": "Cancelled", + "group_unapproved": "Group not approved", + "group_archived": "Archived group", + "prev_month": "Previous month", + "next_month": "Next month", + "loading": "Loading events...", + "error": "Could not load events.", + "more": "+{count} more", + "day_events": "Events on {date}", + "close": "Close", + "subscribe_description": "Add all upcoming events in this network to your Google/Outlook/Yahoo/Apple calendar with the link below:", + "view_toggle": "Events view" } }, "notifications": { diff --git a/client/i18n/locales/fr-BE.json b/client/i18n/locales/fr-BE.json index f3c32a1817..7278e5d3f5 100644 --- a/client/i18n/locales/fr-BE.json +++ b/client/i18n/locales/fr-BE.json @@ -1166,6 +1166,10 @@ "impact": "Impact" }, "stats": { + "all_time": "Depuis le début", + "last_12_months": "12 derniers mois", + "period_toggle": "Période", + "period_range": "De {start} à {end}", "groups": "Repair Café|Repair Cafés", "events": "Événement|Événements", "waste_diverted": "Déchets évités", @@ -1191,7 +1195,8 @@ "delete": "Supprimer", "delete_confirm_title": "Supprimer l'etiquette", "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer l'etiquette \"{name}\"?", - "delete_warning": "Cette action supprimera l'etiquette de {count} Repair Café.|Cette action supprimera l'etiquette de {count} Repair Cafés." + "delete_warning": "Cette action supprimera l'etiquette de {count} Repair Café.|Cette action supprimera l'etiquette de {count} Repair Cafés.", + "add": "Ajouter une etiquette" }, "index": { "all_networks": "Tous les réseaux", @@ -1211,7 +1216,41 @@ "add_groups_select_label": "Choisissez des repair cafés à ajouter", "add_groups_success": "{number} repair café ajouté.|{number} repair cafés ajoutés.", "add_groups_warning_none_selected": "Pas de repair café(s) sélectionné(s)", - "none": "Aucun" + "none": "Aucun", + "coordinators_more": "Afficher {count} de plus", + "coordinators_fewer": "Afficher moins", + "groups_all": "Afficher les {count} repair cafés", + "groups_fewer": "Afficher moins de repair cafés" + }, + "events": { + "title": "Événements à venir", + "list": "Liste", + "calendar": "Calendrier", + "subscribe": "S'abonner au calendrier", + "groups": "Repair cafés", + "all_groups": "Tous les repair cafés", + "range_7": "7 prochains jours", + "range_30": "30 prochains jours", + "range_custom": "Période personnalisée", + "from": "Du", + "to": "Au", + "include_past": "Inclure les événements passés", + "clear": "Effacer les filtres", + "count": "Aucun événement|{count} événement|{count} événements", + "empty": "Aucun événement ne correspond à ces filtres.", + "online": "En ligne", + "cancelled": "Annulé", + "group_unapproved": "Repair café non approuvé", + "group_archived": "Repair café archivé", + "prev_month": "Mois précédent", + "next_month": "Mois suivant", + "loading": "Chargement des événements...", + "error": "Impossible de charger les événements.", + "more": "+{count} de plus", + "day_events": "Événements du {date}", + "close": "Fermer", + "subscribe_description": "Ajoutez tous les événements à venir de ce réseau à votre calendrier Google/Outlook/Yahoo/Apple avec le lien ci-dessous :", + "view_toggle": "Affichage des événements" } }, "non-existent": "Non existant", diff --git a/client/i18n/locales/fr.json b/client/i18n/locales/fr.json index 25003b4a7f..dd4b711e8f 100644 --- a/client/i18n/locales/fr.json +++ b/client/i18n/locales/fr.json @@ -915,6 +915,10 @@ "impact": "Impact" }, "stats": { + "all_time": "Depuis le début", + "last_12_months": "12 derniers mois", + "period_toggle": "Période", + "period_range": "De {start} à {end}", "groups": "Repair Café|Repair Cafés", "events": "Événement|Événements", "waste_diverted": "Déchets évités", @@ -940,7 +944,8 @@ "delete": "Supprimer", "delete_confirm_title": "Supprimer l'etiquette", "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer l'etiquette \"{name}\"?", - "delete_warning": "Cette action supprimera l'etiquette de {count} Repair Café.|Cette action supprimera l'etiquette de {count} Repair Cafés." + "delete_warning": "Cette action supprimera l'etiquette de {count} Repair Café.|Cette action supprimera l'etiquette de {count} Repair Cafés.", + "add": "Ajouter une etiquette" }, "index": { "all_networks": "Tous les réseaux", @@ -960,7 +965,41 @@ "add_groups_select_label": "Choisissez des repair cafés à ajouter", "add_groups_success": "{number} repair café ajouté.|{number} repair cafés ajoutés.", "add_groups_warning_none_selected": "Pas de repair café(s) sélectionné(s)", - "none": "Aucun" + "none": "Aucun", + "coordinators_more": "Afficher {count} de plus", + "coordinators_fewer": "Afficher moins", + "groups_all": "Afficher les {count} repair cafés", + "groups_fewer": "Afficher moins de repair cafés" + }, + "events": { + "title": "Événements à venir", + "list": "Liste", + "calendar": "Calendrier", + "subscribe": "S'abonner au calendrier", + "groups": "Repair cafés", + "all_groups": "Tous les repair cafés", + "range_7": "7 prochains jours", + "range_30": "30 prochains jours", + "range_custom": "Période personnalisée", + "from": "Du", + "to": "Au", + "include_past": "Inclure les événements passés", + "clear": "Effacer les filtres", + "count": "Aucun événement|{count} événement|{count} événements", + "empty": "Aucun événement ne correspond à ces filtres.", + "online": "En ligne", + "cancelled": "Annulé", + "group_unapproved": "Repair café non approuvé", + "group_archived": "Repair café archivé", + "prev_month": "Mois précédent", + "next_month": "Mois suivant", + "loading": "Chargement des événements...", + "error": "Impossible de charger les événements.", + "more": "+{count} de plus", + "day_events": "Événements du {date}", + "close": "Fermer", + "subscribe_description": "Ajoutez tous les événements à venir de ce réseau à votre calendrier Google/Outlook/Yahoo/Apple avec le lien ci-dessous :", + "view_toggle": "Affichage des événements" } }, "non-existent": "Non existant", diff --git a/client/tests/components/networks/NetworkEvents.spec.js b/client/tests/components/networks/NetworkEvents.spec.js new file mode 100644 index 0000000000..4d6fb5db41 --- /dev/null +++ b/client/tests/components/networks/NetworkEvents.spec.js @@ -0,0 +1,242 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import NetworkEvents from '../../../app/components/networks/NetworkEvents.vue' +import { BAlertStub, BButtonStub, BDropdownStub, BModalStub, NuxtLinkStub } from '../../helpers/stubs.js' +import en from '../../../i18n/locales/en.json' +import clientEn from '../../../i18n/locales/client-en.json' + +const NOW = new Date('2038-02-10T12:00:00Z') + +function event(id, overrides = {}) { + return { + id, + title: `Event ${id}`, + start: '2038-02-12T10:00:00Z', + end: '2038-02-12T12:00:00Z', + timezone: 'UTC', + online: false, + cancelled: false, + group: { id: 10, name: 'Fixers United', image: null, approved: true, archived_at: null }, + ...overrides, + } +} + +let events +let wrapper + +function mountIt() { + wrapper = mount(NetworkEvents, { + props: { networkId: 5 }, + attachTo: document.body, + global: { + plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { ...en, ...clientEn } } })], + stubs: { + NuxtLink: { ...NuxtLinkStub, template: '' }, + BAlert: BAlertStub, + BButton: BButtonStub, + BModal: BModalStub, + BDropdown: BDropdownStub, + }, + }, + }) + return wrapper +} + +describe('components/networks/NetworkEvents', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + window.localStorage.clear() + events = vi.fn().mockResolvedValue({ data: [event(1), event(2, { start: '2038-02-20T10:00:00Z', end: '2038-02-20T12:00:00Z', group: { id: 11, name: 'Beta Repairers', approved: true, archived_at: null } })] }) + vi.stubGlobal('useNuxtApp', () => ({ $api: { network: { events } } })) + vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: 'http://api.test' } })) + }) + + afterEach(() => { + wrapper?.unmount() + vi.useRealTimers() + window.localStorage.clear() + }) + + it('shows the calendar by default', async () => { + const w = mountIt() + await flushPromises() + + expect(w.find('[data-testid="network-events-calendar"]').exists()).toBe(true) + expect(w.find('[data-testid="network-events-list"]').exists()).toBe(false) + expect(w.get('[data-testid="network-events-view-calendar"]').classes()).toContain('active') + expect(w.get('[data-testid="network-events-month-title"]').text()).toBe('February 2038') + }) + + it('asks for the visible month, including unapproved events (the server decides who may see them)', async () => { + mountIt() + await flushPromises() + + expect(events).toHaveBeenCalledTimes(1) + const [id, params] = events.mock.calls[0] + expect(id).toBe(5) + expect(params.include_unapproved).toBe(1) + // The window covers the whole month, padded by a day each side. + expect(params.start <= '2038-01-31T23:59:59.999Z').toBe(true) + expect(params.end >= '2038-03-01T00:00:00.000Z').toBe(true) + expect(params.end <= '2038-03-03T00:00:00.000Z').toBe(true) + }) + + it('refetches for the new month when paging', async () => { + const w = mountIt() + await flushPromises() + + await w.get('[data-testid="network-events-next"]').trigger('click') + await flushPromises() + + expect(events).toHaveBeenCalledTimes(2) + expect(w.get('[data-testid="network-events-month-title"]').text()).toBe('March 2038') + }) + + describe('view toggle', () => { + it('switches to the list and remembers the choice', async () => { + const w = mountIt() + await flushPromises() + + await w.get('[data-testid="network-events-view-list"]').trigger('click') + await flushPromises() + + expect(w.find('[data-testid="network-events-list"]').exists()).toBe(true) + expect(w.find('[data-testid="network-events-calendar"]').exists()).toBe(false) + expect(window.localStorage.getItem('networkEventsView')).toBe('list') + }) + + it('a remembered list choice wins over the calendar default', async () => { + window.localStorage.setItem('networkEventsView', 'list') + const w = mountIt() + await flushPromises() + + expect(w.find('[data-testid="network-events-list"]').exists()).toBe(true) + expect(w.get('[data-testid="network-events-view-list"]').classes()).toContain('active') + }) + + it('a remembered calendar choice, or junk, gives the calendar', async () => { + window.localStorage.setItem('networkEventsView', 'nonsense') + const w = mountIt() + await flushPromises() + + expect(w.find('[data-testid="network-events-calendar"]').exists()).toBe(true) + }) + + it('still works when storage is unavailable', async () => { + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied') + }) + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('denied') + }) + + try { + const w = mountIt() + await flushPromises() + expect(w.find('[data-testid="network-events-calendar"]').exists()).toBe(true) + + await w.get('[data-testid="network-events-view-list"]').trigger('click') + expect(w.find('[data-testid="network-events-list"]').exists()).toBe(true) + } finally { + spy.mockRestore() + set.mockRestore() + } + }) + }) + + describe('list view filters', () => { + async function listView() { + window.localStorage.setItem('networkEventsView', 'list') + const w = mountIt() + await flushPromises() + return w + } + + it('asks for the next 30 days by default and hides events that have already finished', async () => { + events.mockResolvedValue({ data: [event(1), event(3, { start: '2038-02-01T10:00:00Z', end: '2038-02-01T12:00:00Z' })] }) + const w = await listView() + + const params = events.mock.calls[0][1] + expect(params.start).toBe(NOW.toISOString()) + expect(params.end).toBe(new Date('2038-03-12T12:00:00Z').toISOString()) + expect(w.find('[data-testid="network-event-row-1"]').exists()).toBe(true) + expect(w.find('[data-testid="network-event-row-3"]').exists()).toBe(false) + }) + + it('includes past events when asked, and offers to clear that filter', async () => { + events.mockResolvedValue({ data: [event(1), event(3, { start: '2038-02-01T10:00:00Z', end: '2038-02-01T12:00:00Z' })] }) + const w = await listView() + expect(w.find('[data-testid="network-events-clear"]').exists()).toBe(false) + + await w.get('[data-testid="network-events-include-past"]').setValue(true) + await flushPromises() + + expect(w.find('[data-testid="network-event-row-3"]').exists()).toBe(true) + expect(events).toHaveBeenCalledTimes(2) + await w.get('[data-testid="network-events-clear"]').trigger('click') + await flushPromises() + expect(w.find('[data-testid="network-event-row-3"]').exists()).toBe(false) + }) + + it('narrows the window to 7 days', async () => { + const w = await listView() + + await w.get('[data-testid="network-events-range"]').setValue('7') + await flushPromises() + + expect(events.mock.calls[1][1].end).toBe(new Date('2038-02-17T12:00:00Z').toISOString()) + }) + + it('filters by group and clears the filter', async () => { + const w = await listView() + expect(w.find('[data-testid="network-event-row-2"]').exists()).toBe(true) + + await w.get('[data-testid="network-events-group-11"]').setValue(true) + + expect(w.find('[data-testid="network-event-row-1"]').exists()).toBe(false) + expect(w.find('[data-testid="network-event-row-2"]').exists()).toBe(true) + expect(w.get('[data-testid="network-events-count"]').text()).toBe('1 event') + + await w.get('[data-testid="network-events-clear"]').trigger('click') + expect(w.find('[data-testid="network-event-row-1"]').exists()).toBe(true) + expect(w.get('[data-testid="network-events-count"]').text()).toBe('2 events') + }) + }) + + it('reports a failure to load', async () => { + events.mockRejectedValue({ status: 500 }) + const w = mountIt() + await flushPromises() + + expect(w.get('[data-testid="network-events-error"]').text()).toBe('Could not load events.') + }) + + it('ignores a slow response that a later one has superseded', async () => { + let resolveFirst + events.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + events.mockResolvedValueOnce({ data: [event(2, { start: '2038-03-05T10:00:00Z', end: '2038-03-05T12:00:00Z' })] }) + const w = mountIt() + + await w.get('[data-testid="network-events-next"]').trigger('click').catch(() => {}) + await flushPromises() + resolveFirst({ data: [event(1)] }) + await flushPromises() + + expect(w.find('[data-testid="network-event-chip-1"]').exists()).toBe(false) + expect(w.find('[data-testid="network-event-chip-2"]').exists()).toBe(true) + }) + + describe('subscribe', () => { + it('shows the network iCal feed on the API origin to copy', async () => { + const w = mountIt() + await flushPromises() + expect(w.find('[data-testid="network-events-subscribe-modal"]').exists()).toBe(false) + + await w.get('[data-testid="network-events-subscribe"]').trigger('click') + + expect(w.get('[data-testid="network-events-calendar-url"]').element.value).toBe('http://api.test/calendar/network/5') + }) + }) +}) diff --git a/client/tests/components/networks/NetworkEventsCalendar.spec.js b/client/tests/components/networks/NetworkEventsCalendar.spec.js new file mode 100644 index 0000000000..a9544212b9 --- /dev/null +++ b/client/tests/components/networks/NetworkEventsCalendar.spec.js @@ -0,0 +1,225 @@ +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import NetworkEventsCalendar from '../../../app/components/networks/NetworkEventsCalendar.vue' +import { GROUP_COLOURS } from '../../../app/utils/networkEvents.js' +import { NuxtLinkStub } from '../../helpers/stubs.js' +import en from '../../../i18n/locales/en.json' +import clientEn from '../../../i18n/locales/client-en.json' + +const NuxtLinkWithAttrs = { ...NuxtLinkStub, template: '' } + +function event(id, overrides = {}) { + return { + id, + title: `Event ${id}`, + start: '2038-02-03T10:00:00Z', + end: '2038-02-03T12:00:00Z', + timezone: 'UTC', + online: false, + cancelled: false, + group: { id: 10, name: 'Fixers United', image: null, approved: true, archived_at: null }, + ...overrides, + } +} + +function manyEvents(n) { + return Array.from({ length: n }, (_, i) => + event(i + 1, { start: `2038-02-03T${String(8 + i).padStart(2, '0')}:00:00Z`, end: `2038-02-03T${String(9 + i).padStart(2, '0')}:00:00Z` }) + ) +} + +let wrapper + +function mountIt(events, month = '2038-02') { + wrapper = mount(NetworkEventsCalendar, { + props: { events, month }, + attachTo: document.body, + global: { + plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { ...en, ...clientEn } } })], + stubs: { NuxtLink: NuxtLinkWithAttrs }, + }, + }) + return wrapper +} + +const DAY = '[data-testid="network-events-day-2038-02-03"]' + +describe('components/networks/NetworkEventsCalendar', () => { + beforeEach(() => { + vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: 'http://api.test' } })) + }) + + afterEach(() => { + wrapper?.unmount() + }) + + it('renders a Monday-first month with its title and one cell per day', () => { + const w = mountIt([]) + + expect(w.get('[data-testid="network-events-month-title"]').text()).toBe('February 2038') + expect(w.findAll('thead th').map((th) => th.text())).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) + expect(w.findAll('tbody td')).toHaveLength(28) + }) + + it('emits prev and next', async () => { + const w = mountIt([]) + + await w.get('[data-testid="network-events-prev"]').trigger('click') + await w.get('[data-testid="network-events-next"]').trigger('click') + + expect(w.emitted('prev')).toHaveLength(1) + expect(w.emitted('next')).toHaveLength(1) + }) + + it('labels each cell with its date and how many events it has', () => { + const w = mountIt([event(1), event(2)]) + + expect(w.get(DAY).attributes('aria-label')).toBe('3 February, 2 events') + expect(w.get('[data-testid="network-events-day-2038-02-04"]').attributes('aria-label')).toBe('4 February, No events') + }) + + it('shows every event as a chip linking to it when there are 5 or fewer, with no "more"', () => { + const w = mountIt(manyEvents(5)) + const chips = w.get(DAY).findAll('[data-testid^="network-event-chip-"]') + + expect(chips).toHaveLength(5) + expect(chips[0].attributes('href')).toBe('/party/view/1') + expect(w.find('[data-testid="network-events-more-2038-02-03"]').exists()).toBe(false) + }) + + it('shows 5 chips and "+N more" for the rest', () => { + const w = mountIt(manyEvents(8)) + + expect(w.get(DAY).findAll('[data-testid^="network-event-chip-"]')).toHaveLength(5) + expect(w.get('[data-testid="network-events-more-2038-02-03"]').text()).toBe('+3 more') + }) + + describe('"+N more" popover', () => { + it('lists all of that day\'s events, each linking through to the event', async () => { + const w = mountIt(manyEvents(8)) + expect(w.find('[data-testid="network-events-popover-2038-02-03"]').exists()).toBe(false) + + await w.get('[data-testid="network-events-more-2038-02-03"]').trigger('click') + + const popover = w.get('[data-testid="network-events-popover-2038-02-03"]') + expect(popover.attributes('aria-label')).toBe('Events on Wednesday 3 February') + const links = popover.findAll('[data-testid^="network-event-chip-"]') + expect(links.map((l) => l.attributes('href'))).toEqual(Array.from({ length: 8 }, (_, i) => `/party/view/${i + 1}`)) + }) + + it('is anchored to the day cell, and only one is open at a time', async () => { + const events = [...manyEvents(6), event(20, { start: '2038-02-04T10:00:00Z', end: '2038-02-04T11:00:00Z' }), ...Array.from({ length: 5 }, (_, i) => event(30 + i, { start: '2038-02-04T12:00:00Z', end: '2038-02-04T13:00:00Z' }))] + const w = mountIt(events) + + await w.get('[data-testid="network-events-more-2038-02-03"]').trigger('click') + expect(w.get(DAY).find('[data-testid="network-events-popover-2038-02-03"]').exists()).toBe(true) + + await w.get('[data-testid="network-events-more-2038-02-04"]').trigger('click') + expect(w.findAll('[role="dialog"]')).toHaveLength(1) + expect(w.get('[data-testid="network-events-day-2038-02-04"]').find('[role="dialog"]').exists()).toBe(true) + }) + + it('toggles closed on a second click, via the close button, Escape and a click elsewhere', async () => { + const w = mountIt(manyEvents(7)) + const more = () => w.get('[data-testid="network-events-more-2038-02-03"]') + const isOpen = () => w.find('[role="dialog"]').exists() + + await more().trigger('click') + expect(isOpen()).toBe(true) + await more().trigger('click') + expect(isOpen()).toBe(false) + + await more().trigger('click') + await w.get('[data-testid="network-events-popover-close"]').trigger('click') + expect(isOpen()).toBe(false) + + await more().trigger('click') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await w.vm.$nextTick() + expect(isOpen()).toBe(false) + + await more().trigger('click') + document.body.click() + await w.vm.$nextTick() + expect(isOpen()).toBe(false) + }) + + it('stays open when clicking inside it', async () => { + const w = mountIt(manyEvents(7)) + await w.get('[data-testid="network-events-more-2038-02-03"]').trigger('click') + + await w.get('[data-testid="network-events-popover-2038-02-03"]').trigger('click') + + expect(w.find('[role="dialog"]').exists()).toBe(true) + }) + + it('closes when moving to another month', async () => { + const w = mountIt(manyEvents(7)) + await w.get('[data-testid="network-events-more-2038-02-03"]').trigger('click') + + await w.get('[data-testid="network-events-next"]').trigger('click') + + expect(w.find('[role="dialog"]').exists()).toBe(false) + }) + + it('opens from the count dot that stands in for the chips on small screens', async () => { + const w = mountIt(manyEvents(2)) + const dot = w.get('[data-testid="network-events-count-2038-02-03"]') + expect(dot.text()).toBe('2') + expect(dot.classes()).toContain('d-md-none') + + await dot.trigger('click') + + expect(w.get('[data-testid="network-events-popover-2038-02-03"]').findAll('[data-testid^="network-event-chip-"]')).toHaveLength(2) + }) + }) + + describe('chips', () => { + it('are coloured per group, the same for every event of a group and different between groups', () => { + const other = { id: 11, name: 'Other', image: null, approved: true, archived_at: null } + const w = mountIt([event(1), event(2), event(3, { group: other })]) + const colour = (id) => w.get(`[data-testid="network-event-chip-${id}"]`).attributes('style') + + expect(colour(1)).toBe(colour(2)) + expect(colour(1)).not.toBe(colour(3)) + expect(GROUP_COLOURS.some((c) => colour(1).includes(c))).toBe(true) + }) + + it('show the group logo when it has one and the time in the event\'s timezone', () => { + const w = mountIt([ + event(1, { group: { id: 10, name: 'Fixers United', image: 'logo.png', approved: true, archived_at: null } }), + event(2, { start: '2038-02-03T23:30:00Z', end: '2038-02-04T01:00:00Z', timezone: 'Europe/Paris' }), + ]) + + expect(w.get('[data-testid="network-event-chip-1"] img').attributes('src')).toBe('http://api.test/uploads/logo.png') + expect(w.find('[data-testid="network-event-chip-2"] img').exists()).toBe(false) + expect(w.get('[data-testid="network-event-chip-1"]').text()).toBe('10:00Fixers United') + // 23:30 UTC is 00:30 the next day in Paris, so it sits on the 4th. + expect(w.get('[data-testid="network-events-day-2038-02-04"] [data-testid="network-event-chip-2"]').text()).toContain('00:30') + }) + + it('are struck through when cancelled, dashed for an unapproved or archived group, and say why on hover', () => { + const w = mountIt([ + event(1, { cancelled: true }), + event(2, { group: { id: 11, name: 'New', approved: false, archived_at: null } }), + event(3, { group: { id: 12, name: 'Old', approved: true, archived_at: '2020-01-01T00:00:00Z' } }), + event(4), + ]) + const chip = (id) => w.get(`[data-testid="network-event-chip-${id}"]`) + + expect(chip(1).classes()).toContain('cancelled') + expect(chip(1).classes()).not.toContain('dashed') + expect(chip(1).attributes('title')).toBe('Event 1 (Cancelled)') + + expect(chip(2).classes()).toContain('dashed') + expect(chip(2).attributes('title')).toBe('Event 2 (Group not approved)') + expect(chip(3).classes()).toContain('dashed') + expect(chip(3).attributes('title')).toBe('Event 3 (Archived group)') + + expect(chip(4).classes()).not.toContain('dashed') + expect(chip(4).classes()).not.toContain('cancelled') + expect(chip(4).attributes('title')).toBe('Event 4') + }) + }) +}) diff --git a/client/tests/components/networks/NetworkEventsList.spec.js b/client/tests/components/networks/NetworkEventsList.spec.js new file mode 100644 index 0000000000..a97dd814f3 --- /dev/null +++ b/client/tests/components/networks/NetworkEventsList.spec.js @@ -0,0 +1,80 @@ +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NetworkEventsList from '../../../app/components/networks/NetworkEventsList.vue' +import { NuxtLinkStub } from '../../helpers/stubs.js' +import en from '../../../i18n/locales/en.json' +import clientEn from '../../../i18n/locales/client-en.json' + +function event(id, overrides = {}) { + return { + id, + title: `Event ${id}`, + start: '2038-02-03T10:00:00Z', + end: '2038-02-03T12:00:00Z', + timezone: 'UTC', + online: false, + cancelled: false, + group: { id: 10, name: 'Fixers United', image: null, approved: true, archived_at: null }, + ...overrides, + } +} + +function mountIt(events) { + return mount(NetworkEventsList, { + props: { events }, + global: { + plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { ...en, ...clientEn } } })], + stubs: { NuxtLink: { ...NuxtLinkStub, template: '' } }, + }, + }) +} + +describe('components/networks/NetworkEventsList', () => { + beforeEach(() => { + vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBase: 'http://api.test' } })) + }) + + it('says so when there are no events', () => { + expect(mountIt([]).get('[data-testid="network-events-empty"]').text()).toBe('No events match these filters.') + }) + + it('groups events under month headings in order, each row linking to its event', () => { + const w = mountIt([event(1), event(2, { start: '2038-03-10T18:00:00Z', end: '2038-03-10T19:30:00Z' })]) + + expect(w.findAll('h3').map((h) => h.text())).toEqual(['February 2038', 'March 2038']) + const row = w.get('[data-testid="network-event-row-2"]') + expect(row.attributes('href')).toBe('/party/view/2') + expect(row.text()).toContain('18:00 - 19:30') + expect(row.text()).toContain('Fixers United') + }) + + it('shows times in the event\'s own timezone', () => { + const w = mountIt([event(1, { start: '2038-02-03T10:00:00Z', end: '2038-02-03T12:00:00Z', timezone: 'Europe/Paris' })]) + + expect(w.get('[data-testid="network-event-row-1"]').text()).toContain('11:00 - 13:00') + }) + + it('marks online, cancelled, unapproved and archived', () => { + const w = mountIt([ + event(1, { online: true }), + event(2, { cancelled: true }), + event(3, { group: { id: 11, name: 'New', approved: false, archived_at: null } }), + event(4, { group: { id: 12, name: 'Old', approved: true, archived_at: '2020-01-01T00:00:00Z' } }), + ]) + + expect(w.get('[data-testid="network-event-row-1"]').text()).toContain('Online') + expect(w.get('[data-testid="network-event-row-2"]').classes()).toContain('cancelled') + expect(w.get('[data-testid="network-event-row-2"]').text()).toContain('Cancelled') + expect(w.get('[data-testid="network-event-row-3"] [data-testid="unapproved-badge"]').text()).toBe('Group not approved') + expect(w.get('[data-testid="network-event-row-4"] [data-testid="archived-badge"]').text()).toBe('Archived group') + expect(w.find('[data-testid="network-event-row-1"] [data-testid="unapproved-badge"]').exists()).toBe(false) + }) + + it('shows the group logo when it has one', () => { + const w = mountIt([event(1, { group: { id: 10, name: 'Fixers', image: 'logo.png', approved: true, archived_at: null } }), event(2)]) + + expect(w.get('[data-testid="network-event-row-1"] img').attributes('src')).toBe('http://api.test/uploads/logo.png') + expect(w.find('[data-testid="network-event-row-2"] img').exists()).toBe(false) + }) +}) diff --git a/client/tests/components/networks/PeriodToggle.spec.js b/client/tests/components/networks/PeriodToggle.spec.js new file mode 100644 index 0000000000..b31710d5c6 --- /dev/null +++ b/client/tests/components/networks/PeriodToggle.spec.js @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' +import PeriodToggle from '../../../app/components/networks/PeriodToggle.vue' +import en from '../../../i18n/locales/en.json' + +function mountIt(modelValue) { + return mount(PeriodToggle, { + props: modelValue === undefined ? {} : { modelValue }, + global: { plugins: [createI18n({ legacy: false, locale: 'en', messages: { en } })] }, + }) +} + +describe('components/networks/PeriodToggle', () => { + it('defaults to all time', () => { + const w = mountIt() + + expect(w.get('[data-testid="period-all"]').classes()).toContain('active') + expect(w.get('[data-testid="period-all"]').attributes('aria-pressed')).toBe('true') + expect(w.get('[data-testid="period-last12"]').classes()).not.toContain('active') + expect(w.get('[data-testid="period-all"]').text()).toBe('All time') + expect(w.get('[data-testid="period-last12"]').text()).toBe('Last 12 months') + expect(w.get('[role="group"]').attributes('aria-label')).toBe('Period') + }) + + it('shows the current period as pressed and emits the chosen one', async () => { + const w = mountIt('last_12_months') + expect(w.get('[data-testid="period-last12"]').classes()).toContain('active') + + await w.get('[data-testid="period-all"]').trigger('click') + expect(w.emitted('update:modelValue')[0]).toEqual(['all']) + + await w.get('[data-testid="period-last12"]').trigger('click') + expect(w.emitted('update:modelValue')[1]).toEqual(['last_12_months']) + }) +}) diff --git a/client/tests/utils/networkEvents.spec.js b/client/tests/utils/networkEvents.spec.js new file mode 100644 index 0000000000..ec1bc64fcc --- /dev/null +++ b/client/tests/utils/networkEvents.spec.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { GROUP_COLOURS, bucketByDay, groupColour, localDay, monthGrid, shiftMonth } from '../../app/utils/networkEvents.js' + +describe('utils/networkEvents', () => { + it('puts an event on its own timezone day, not the UTC one', () => { + // 23:00 UTC on 31 Jan is already 12:00 on 1 Feb in Auckland (UTC+13). + expect(localDay({ start: '2038-01-31T23:00:00Z', timezone: 'Pacific/Auckland' })).toBe('2038-02-01') + expect(localDay({ start: '2038-01-31T23:00:00Z', timezone: 'Europe/London' })).toBe('2038-01-31') + expect(localDay({ start: '2038-01-31T23:00:00Z' })).toBe('2038-01-31') + }) + + it('buckets events by day, earliest first', () => { + const buckets = bucketByDay([ + { id: 2, start: '2038-01-05T15:00:00Z' }, + { id: 1, start: '2038-01-05T09:00:00Z' }, + { id: 3, start: '2038-01-06T09:00:00Z' }, + ]) + + expect(buckets['2038-01-05'].map((e) => e.id)).toEqual([1, 2]) + expect(buckets['2038-01-06'].map((e) => e.id)).toEqual([3]) + expect(bucketByDay(undefined)).toEqual({}) + }) + + it('builds Monday-first whole weeks for a month', () => { + // February 2038 starts on a Monday and has 28 days: exactly four weeks. + const feb = monthGrid('2038-02', 1) + expect(feb).toHaveLength(4) + expect(feb[0][0]).toEqual({ date: '2038-02-01', inMonth: true, day: 1 }) + + // January 2038 starts on a Friday, so the first row leads with Dec days. + const jan = monthGrid('2038-01', 1) + expect(jan[0][0]).toEqual({ date: '2037-12-28', inMonth: false, day: 28 }) + expect(jan[0][4]).toEqual({ date: '2038-01-01', inMonth: true, day: 1 }) + jan.forEach((week) => expect(week).toHaveLength(7)) + }) + + it('shifts months across year boundaries', () => { + expect(shiftMonth('2038-01', -1)).toBe('2037-12') + expect(shiftMonth('2038-12', 1)).toBe('2039-01') + }) + + describe('groupColour', () => { + it('is stable for a group and always from the palette', () => { + expect(groupColour(42)).toBe(groupColour(42)) + expect(groupColour('42')).toBe(groupColour(42)) + for (let id = 0; id < 50; id++) expect(GROUP_COLOURS).toContain(groupColour(id)) + }) + + it('gives consecutive group ids different colours', () => { + for (let id = 1; id < 40; id++) expect(groupColour(id)).not.toBe(groupColour(id + 1)) + }) + + it('has a palette of accessible fills: 4.5:1 against white text', () => { + const luminance = (hex) => { + const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255) + const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + } + + GROUP_COLOURS.forEach((c) => expect(1.05 / (luminance(c) + 0.05), c).toBeGreaterThanOrEqual(4.5)) + }) + + it('copes with a missing id', () => { + expect(GROUP_COLOURS).toContain(groupColour(undefined)) + }) + }) +}) diff --git a/lang/en/networks.php b/lang/en/networks.php index f4e6ef0e37..3ab1672fe2 100644 --- a/lang/en/networks.php +++ b/lang/en/networks.php @@ -52,7 +52,8 @@ 'delete' => 'Delete', 'delete_confirm_title' => 'Delete Tag', 'delete_confirm_message' => 'Are you sure you want to delete the tag ":name"?', - 'delete_warning' => '{1} This will remove the tag from :count group.|[2,*] This will remove the tag from :count groups.' + 'delete_warning' => '{1} This will remove the tag from :count group.|[2,*] This will remove the tag from :count groups.', + 'add' => 'Add tag', ], 'index' => [ 'your_networks' => 'Your networks', @@ -71,6 +72,10 @@ 'add_groups_warning_none_selected' => 'No groups selected.', 'add_groups_success' => '{1} :number group added.|[2,*] :number groups added.', 'none' => 'None', + 'coordinators_more' => 'Show :count more', + 'coordinators_fewer' => 'Show fewer', + 'groups_all' => 'Show all :count groups', + 'groups_fewer' => 'Show fewer groups', ], 'edit' => [ 'label_reporting_url' => 'Reporting dashboard address', @@ -105,6 +110,10 @@ 'next_month' => 'Next month', 'loading' => 'Loading events...', 'error' => 'Could not load events.', + 'more' => '+:count more', + 'day_events' => 'Events on :date', + 'close' => 'Close', + 'subscribe_description' => 'Add all upcoming events in this network to your Google/Outlook/Yahoo/Apple calendar with the link below:', 'view_toggle' => 'Events view', ], ]; diff --git a/lang/fr-BE/networks.php b/lang/fr-BE/networks.php index 642dce4cc1..9cc6c1b801 100644 --- a/lang/fr-BE/networks.php +++ b/lang/fr-BE/networks.php @@ -54,6 +54,7 @@ 'delete_confirm_title' => 'Supprimer l\'etiquette', 'delete_confirm_message' => 'Êtes-vous sûr de vouloir supprimer l\'etiquette ":name"?', 'delete_warning' => '{1} Cette action supprimera l\'etiquette de :count Repair Café.|[2,*] Cette action supprimera l\'etiquette de :count Repair Cafés.', + 'add' => 'Ajouter une etiquette', ], 'index' => [ 'all_networks' => 'Tous les réseaux', @@ -74,6 +75,10 @@ 'add_groups_success' => '{1} :number repair café ajouté.|[2,*] :number repair cafés ajoutés.', 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', + 'coordinators_more' => 'Afficher :count de plus', + 'coordinators_fewer' => 'Afficher moins', + 'groups_all' => 'Afficher les :count repair cafés', + 'groups_fewer' => 'Afficher moins de repair cafés', ], 'events' => [ 'title' => 'Événements à venir', @@ -99,6 +104,10 @@ 'next_month' => 'Mois suivant', 'loading' => 'Chargement des événements...', 'error' => 'Impossible de charger les événements.', + 'more' => '+:count de plus', + 'day_events' => 'Événements du :date', + 'close' => 'Fermer', + 'subscribe_description' => 'Ajoutez tous les événements à venir de ce réseau à votre calendrier Google/Outlook/Yahoo/Apple avec le lien ci-dessous :', 'view_toggle' => 'Affichage des événements', ], ]; diff --git a/lang/fr/networks.php b/lang/fr/networks.php index 642dce4cc1..9cc6c1b801 100644 --- a/lang/fr/networks.php +++ b/lang/fr/networks.php @@ -54,6 +54,7 @@ 'delete_confirm_title' => 'Supprimer l\'etiquette', 'delete_confirm_message' => 'Êtes-vous sûr de vouloir supprimer l\'etiquette ":name"?', 'delete_warning' => '{1} Cette action supprimera l\'etiquette de :count Repair Café.|[2,*] Cette action supprimera l\'etiquette de :count Repair Cafés.', + 'add' => 'Ajouter une etiquette', ], 'index' => [ 'all_networks' => 'Tous les réseaux', @@ -74,6 +75,10 @@ 'add_groups_success' => '{1} :number repair café ajouté.|[2,*] :number repair cafés ajoutés.', 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', + 'coordinators_more' => 'Afficher :count de plus', + 'coordinators_fewer' => 'Afficher moins', + 'groups_all' => 'Afficher les :count repair cafés', + 'groups_fewer' => 'Afficher moins de repair cafés', ], 'events' => [ 'title' => 'Événements à venir', @@ -99,6 +104,10 @@ 'next_month' => 'Mois suivant', 'loading' => 'Chargement des événements...', 'error' => 'Impossible de charger les événements.', + 'more' => '+:count de plus', + 'day_events' => 'Événements du :date', + 'close' => 'Fermer', + 'subscribe_description' => 'Ajoutez tous les événements à venir de ce réseau à votre calendrier Google/Outlook/Yahoo/Apple avec le lien ci-dessous :', 'view_toggle' => 'Affichage des événements', ], ]; From a41fa67dad2038e8ef5023bee9f6f27b98854e5e Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:07:39 +0000 Subject: [PATCH 3/5] Network page: moderation first, period toggle, coordinators row, events, 5 groups, full-width tags Update the page, tags and groups specs and the e2e for the new order and the Add tag button. Co-Authored-By: Claude Opus 5.5 --- .../networks/NetworkGroupsMapList.vue | 15 +- .../networks/NetworkTagsManager.vue | 17 +- client/app/pages/networks/[id]/index.vue | 148 +++++++++++++----- client/e2e/grouptags.test.js | 9 +- client/e2e/networks.test.js | 33 ++++ .../networks/NetworkGroupsMapList.spec.js | 32 +++- .../networks/NetworkTagsManager.spec.js | 22 ++- client/tests/pages/networks/show.spec.js | 133 +++++++++++++++- 8 files changed, 355 insertions(+), 54 deletions(-) diff --git a/client/app/components/networks/NetworkGroupsMapList.vue b/client/app/components/networks/NetworkGroupsMapList.vue index 6e7dac7acd..438eae66ad 100644 --- a/client/app/components/networks/NetworkGroupsMapList.vue +++ b/client/app/components/networks/NetworkGroupsMapList.vue @@ -25,6 +25,10 @@ const props = defineProps({ }, }) +// The list under the map starts at this many groups; "Show all" reveals the +// rest (the map always shows every group). +const LIST_LIMIT = 5 + const { t } = useI18n() const groupsStore = useGroupsStore() @@ -94,6 +98,9 @@ const rows = computed(() => }) ) +const showAll = ref(false) +const visibleRows = computed(() => (showAll.value ? rows.value : rows.value.slice(0, LIST_LIMIT))) + const selectedGroup = computed(() => { if (selectedGroupId.value === null) return null const row = rows.value.find((r) => r.id === selectedGroupId.value) @@ -144,12 +151,18 @@ onMounted(() => { + +
+ + {{ showAll ? t('networks.show.groups_fewer') : t('networks.show.groups_all', { count: rows.length }) }} + +
diff --git a/client/app/components/networks/NetworkTagsManager.vue b/client/app/components/networks/NetworkTagsManager.vue index 1132f7f46f..85318c84e9 100644 --- a/client/app/components/networks/NetworkTagsManager.vue +++ b/client/app/components/networks/NetworkTagsManager.vue @@ -6,8 +6,9 @@ import { useNetworksStore } from '~/stores/networks.js' // Network tag CRUD for /networks/{id}'s "Group Tags" section (parity-v2/ // networks.md gap #4). Legacy NetworkPage.vue's tags-management block is // the functional spec: bordered card rows (name + count, description shown -// inline underneath, pencil/trash icon buttons) plus an always-visible -// inline create form below the list - NOT components/admin/AdminCrudTable. +// inline underneath, pencil/trash icon buttons) plus an inline create form +// below the list (hidden until "Add tag" is clicked, per the network page +// rework) - NOT components/admin/AdminCrudTable. // vue's generic modal-driven create flow + name-as-edit-link + text // "Delete" button (AdminCrudTable is shared by 5 other reference-data pages // which is a DIFFERENT shape, so a bespoke component here is the parity @@ -32,6 +33,8 @@ const error = computed(() => networksStore.tags.error) const newTagName = ref('') const newTagDescription = ref('') const creating = ref(false) +// The create inputs stay out of the way until "Add tag" is clicked. +const showCreate = ref(false) const createError = ref('') const showEditModal = ref(false) @@ -62,6 +65,7 @@ async function createTag() { }) newTagName.value = '' newTagDescription.value = '' + showCreate.value = false } catch (err) { createError.value = err?.data?.message || t('networks.tags.create_error') } finally { @@ -174,7 +178,11 @@ async function performDelete() { - + + {{ t('networks.tags.add') }} + + + {{ t('networks.tags.create') }} + + {{ t('partials.cancel') }} +
{{ createError }}
diff --git a/client/app/pages/networks/[id]/index.vue b/client/app/pages/networks/[id]/index.vue index 257410021f..397e94046d 100644 --- a/client/app/pages/networks/[id]/index.vue +++ b/client/app/pages/networks/[id]/index.vue @@ -11,6 +11,8 @@ import NetworkGroupsModerationTable from '~/components/networks/NetworkGroupsMod import NetworkEventsModerationTable from '~/components/networks/NetworkEventsModerationTable.vue' import NetworkTagsManager from '~/components/networks/NetworkTagsManager.vue' import NetworkGroupsMapList from '~/components/networks/NetworkGroupsMapList.vue' +import NetworkEvents from '~/components/networks/NetworkEvents.vue' +import PeriodToggle from '~/components/networks/PeriodToggle.vue' // /networks/{id} - resources/views/networks/show.blade.php + // resources/js/components/NetworkPage.vue (design.md §6.2 Phase E task E1). @@ -40,14 +42,17 @@ import NetworkGroupsMapList from '~/components/networks/NetworkGroupsMapList.vue // name-only list develop never had anywhere) - legacy's // GroupsRequiringModeration/EventsRequiringModeration render the FULL // GroupsTable/GroupEventScrollTable here, not a bare name list. Section -// ORDER also matches legacy exactly (gap #6): Header -> Impact -> About -> -// Coordinators -> Groups-requiring-moderation -> Events-requiring-moderation -// -> Groups -> Tags. Logo management is NOT here: develop puts it on +// ORDER (network page rework): Header -> Groups/Events requiring moderation +// (managers only, at the top because they are the things to act on) -> Impact +// (with the All time / Last 12 months toggle) -> About -> Coordinators (one +// row, "Show N more") -> Upcoming events (calendar by default) -> Groups map +// and list (5 groups, then "Show all") -> Tags (full width, create inputs +// behind "Add tag"). Logo management is NOT here: develop puts it on // networks/{id}/edit (networks/edit.blade.php), which now exists as // pages/networks/[id]/edit.vue. definePageMeta({ auth: true }) -const { t } = useI18n() +const { t, locale } = useI18n() const route = useRoute() const { hasRole } = useAuth() const sessionStore = useSessionStore() @@ -64,6 +69,52 @@ const network = computed(() => networksStore.current.data) useHead({ title: computed(() => network.value?.name || t('networks.general.networks')) }) +// Impact figures: all time (already embedded in the network) or the last 12 +// completed calendar months (GET /api/v2/networks/{id}/stats?period= +// last_12_months, which also says which months it covered). Fetched once per +// period, so switching back is instant. +const period = ref('all') +const statsByPeriod = ref({}) +const periodLoading = ref(false) +const periodError = ref(false) + +const displayedStats = computed(() => + period.value === 'all' ? network.value?.stats : statsByPeriod.value[period.value] +) + +const periodLabel = computed(() => { + const covered = period.value !== 'all' && statsByPeriod.value[period.value]?.period + if (!covered) return '' + const fmt = (d) => new Date(`${d}T00:00:00Z`).toLocaleDateString(locale.value, { month: 'long', year: 'numeric', timeZone: 'UTC' }) + return t('networks.stats.period_range', { start: fmt(covered.start), end: fmt(covered.end) }) +}) + +async function setPeriod(next) { + period.value = next + periodError.value = false + if (next === 'all' || statsByPeriod.value[next]) return + + periodLoading.value = true + try { + const stats = await networksStore.fetchStats(id.value, { period: next }) + statsByPeriod.value = { ...statsByPeriod.value, [next]: stats } + } catch { + periodError.value = true + } finally { + // A later toggle may have moved on; only the current period's request + // controls the spinner. + if (period.value === next) periodLoading.value = false + } +} + +// Coordinators sit in one row; the rest are behind "Show N more". +const COORDINATORS_SHOWN = 4 +const showAllCoordinators = ref(false) +const coordinators = computed(() => network.value?.coordinators || []) +const visibleCoordinators = computed(() => + showAllCoordinators.value ? coordinators.value : coordinators.value.slice(0, COORDINATORS_SHOWN) +) + const showDescriptionModal = ref(false) const showAssociateModal = ref(false) @@ -185,9 +236,36 @@ function retry() { -
+ + + +

{{ t('networks.general.impact') }}

- + +

{{ periodLabel }}

+ + {{ t('client.networks.stats_unavailable') }} + +

-
+

{{ t('networks.general.coordinators') }}

-
+
{{ coordinator.name }} +
- - +
+

{{ t('networks.events.title') }}

+ +
@@ -259,18 +329,12 @@ function retry() {
- -
-
-
-

{{ t('networks.tags.title') }}

- -
-
-
- + +
+

{{ t('networks.tags.title') }}

+ +
diff --git a/client/e2e/grouptags.test.js b/client/e2e/grouptags.test.js index 91f9d8a32b..edf40f2090 100644 --- a/client/e2e/grouptags.test.js +++ b/client/e2e/grouptags.test.js @@ -76,10 +76,10 @@ async function openNetworkTagsPage(page, networkId) { } async function createNetworkTag(page, networkId, name) { - // NetworkTagsManager renders an always-visible inline create form - // (network-tags-create-*), not a modal opened by an add button. Earlier this - // helper drove a modal UI that was never built, so it hung 360s waiting for - // tag-add-button and timed the whole e2e job out. + // NetworkTagsManager's inline create form (network-tags-create-*) stays + // hidden until "Add tag" is clicked, and hides again after a successful + // create. It is not a modal. + await page.getByTestId('network-tags-add').click() await page.getByTestId('network-tags-create-name').fill(name) const [response] = await Promise.all([ @@ -212,6 +212,7 @@ for (const { label, user } of ROLES) { const tagName = `${label} Dup Tag ${Date.now()}` await createNetworkTag(page, networkId, tagName) + await page.getByTestId('network-tags-add').click() await page.getByTestId('network-tags-create-name').fill(tagName) await page.getByTestId('network-tags-create-submit').click() diff --git a/client/e2e/networks.test.js b/client/e2e/networks.test.js index 44c636077c..1a36b514a2 100644 --- a/client/e2e/networks.test.js +++ b/client/e2e/networks.test.js @@ -67,4 +67,37 @@ test.describe('network management', () => { await expect(page.getByTestId('network-edit-forbidden')).toHaveCount(0) await expect(page.getByTestId('network-logo-upload')).toBeVisible() }) + + test('Network page puts moderation above Impact and shows the events calendar by default', async ({ page }) => { + test.slow() + await login(page, USERS.admin) + const networkId = await getNetworkId(page) + + const [eventsResponse] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes(`/api/v2/networks/${networkId}/events`)), + page.goto(`/networks/${networkId}`, { waitUntil: 'domcontentloaded' }), + ]) + expect(eventsResponse.status()).toBe(200) + + // Calendar is the default view (unless a view was remembered in localStorage). + await expect(page.getByTestId('network-events-calendar')).toBeVisible({ timeout: 10000 }) + + // Section order: groups to moderate, events to moderate, Impact, ..., Upcoming events. + const top = async (testId) => (await page.getByTestId(testId).boundingBox()).y + const groupsModeration = await top('network-groups-moderation') + const eventsModeration = await top('network-events-moderation') + const impact = await top('network-show-impact') + const events = await top('network-show-events') + expect(groupsModeration).toBeLessThan(eventsModeration) + expect(eventsModeration).toBeLessThan(impact) + expect(impact).toBeLessThan(events) + + // Last 12 months asks the stats API for that period. + const [statsResponse] = await Promise.all([ + page.waitForResponse((resp) => resp.url().includes(`/api/v2/networks/${networkId}/stats`) && resp.url().includes('period=last_12_months')), + page.getByTestId('period-last12').click(), + ]) + expect(statsResponse.status()).toBe(200) + await expect(page.getByTestId('network-show-period-label')).toBeVisible() + }) }) diff --git a/client/tests/components/networks/NetworkGroupsMapList.spec.js b/client/tests/components/networks/NetworkGroupsMapList.spec.js index 30835552e0..6b34a014fc 100644 --- a/client/tests/components/networks/NetworkGroupsMapList.spec.js +++ b/client/tests/components/networks/NetworkGroupsMapList.spec.js @@ -16,12 +16,14 @@ const GroupsTableStub = { template: '
', } +const BButtonStub = { template: '' } + function mountIt(props = {}) { return mount(NetworkGroupsMapList, { props: { networkId: 5, ...props }, global: { plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: {} }, missingWarn: false })], - stubs: { GroupMap: GroupMapStub, GroupsTable: GroupsTableStub, GroupInfoModal: true, BAlert: true }, + stubs: { GroupMap: GroupMapStub, GroupsTable: GroupsTableStub, GroupInfoModal: true, BAlert: true, BButton: BButtonStub }, }, }) } @@ -83,4 +85,32 @@ describe('NetworkGroupsMapList', () => { expect(w.find('[data-testid="stub-table"]').attributes('data-filters')).toBe('true') expect(w.find('[data-testid="stub-table"]').attributes('data-tags')).toBe('true') }) + + describe('list length', () => { + beforeEach(() => { + store.names = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Group ${i + 1}`, network_ids: [5], tag_ids: [], archived_at: null })) + }) + + it('lists only 5 groups to start with, while the map keeps every pin', () => { + const w = mountIt() + expect(w.find('[data-testid="stub-table"]').attributes('data-ids')).toBe('1,2,3,4,5') + expect(w.find('[data-testid="stub-map"]').attributes('data-ids')).toBe('1,2,3,4,5,6,7,8') + expect(w.find('[data-testid="network-groups-show-all"]').text()).toBe('networks.show.groups_all') + }) + + it('Show all reveals the rest and Show fewer collapses it again', async () => { + const w = mountIt() + await w.get('[data-testid="network-groups-show-all"]').trigger('click') + expect(w.find('[data-testid="stub-table"]').attributes('data-ids')).toBe('1,2,3,4,5,6,7,8') + + await w.get('[data-testid="network-groups-show-all"]').trigger('click') + expect(w.find('[data-testid="stub-table"]').attributes('data-ids')).toBe('1,2,3,4,5') + }) + + it('has no Show all control when there are 5 or fewer groups', () => { + store.names = store.names.slice(0, 5) + const w = mountIt() + expect(w.find('[data-testid="network-groups-show-all"]').exists()).toBe(false) + }) + }) }) diff --git a/client/tests/components/networks/NetworkTagsManager.spec.js b/client/tests/components/networks/NetworkTagsManager.spec.js index 8164d94af8..83b35e902a 100644 --- a/client/tests/components/networks/NetworkTagsManager.spec.js +++ b/client/tests/components/networks/NetworkTagsManager.spec.js @@ -96,18 +96,38 @@ describe('components/networks/NetworkTagsManager', () => { expect(deleteIcon.attributes('width')).toBe('20') }) - it('always shows the inline create form (not a modal-behind-a-button)', async () => { + it('hides the create inputs until "Add tag" is clicked, then creates inline and hides them again', async () => { store.tags.data = [] store.createTag = vi.fn().mockResolvedValue({ id: 2, name: 'New Tag', description: null, groups_count: 0 }) const wrapper = mountComponent() + expect(wrapper.find('[data-testid="network-tags-create-form"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="network-tags-add"]').text()).toBe('Add tag') + + await wrapper.get('[data-testid="network-tags-add"]').trigger('click') + const form = wrapper.get('[data-testid="network-tags-create-form"]') + expect(wrapper.find('[data-testid="network-tags-add"]').exists()).toBe(false) await form.get('[data-testid="network-tags-create-name"]').setValue('New Tag') await form.trigger('submit') await flushPromises() expect(store.createTag).toHaveBeenCalledWith(1, { name: 'New Tag', description: null }) + expect(wrapper.find('[data-testid="network-tags-create-form"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="network-tags-add"]').exists()).toBe(true) + }) + + it('cancelling the create form hides it without creating anything', async () => { + store.tags.data = [] + store.createTag = vi.fn() + + const wrapper = mountComponent() + await wrapper.get('[data-testid="network-tags-add"]').trigger('click') + await wrapper.get('[data-testid="network-tags-create-cancel"]').trigger('click') + + expect(wrapper.find('[data-testid="network-tags-create-form"]').exists()).toBe(false) + expect(store.createTag).not.toHaveBeenCalled() }) it('opens the edit modal with the tag pre-filled and saves via updateTag', async () => { diff --git a/client/tests/pages/networks/show.spec.js b/client/tests/pages/networks/show.spec.js index 9cffdf7f74..b962527e3f 100644 --- a/client/tests/pages/networks/show.spec.js +++ b/client/tests/pages/networks/show.spec.js @@ -20,8 +20,8 @@ const BModalStub = { template: '
', } const NetworkStatsStub = { - props: ['stats', 'groupsCount'], - template: '
', + props: ['stats', 'groupsCount', 'loading'], + template: '
', } const AssociateGroupsModalStub = { props: ['show', 'networkId', 'networkName', 'candidates'], @@ -44,6 +44,10 @@ const NetworkGroupsMapListStub = { props: ['networkId', 'canManageTags'], template: '
', } +const NetworkEventsStub = { + props: ['networkId'], + template: '
', +} const GroupsTableStub = { props: ['groups', 'showJoin'], template: '
', @@ -63,6 +67,7 @@ const GLOBAL_STUBS = { NetworkEventsModerationTable: NetworkEventsModerationTableStub, GroupsTable: GroupsTableStub, NetworkGroupsMapList: NetworkGroupsMapListStub, + NetworkEvents: NetworkEventsStub, } function setLoggedInUser(user) { @@ -266,6 +271,130 @@ describe('pages/networks/[id]', () => { expect(card8.get('img').attributes('src')).toBe('/images/placeholder-avatar.webp') }) + // Network page rework: the order of the sections is part of the design. + it('lays the page out as moderation, impact, about, coordinators, events, groups, tags', async () => { + networksStore.fetchCurrent = vi.fn().mockImplementation(async () => { + networksStore.current.data = { ...NETWORK, coordinators: [{ id: 7, name: 'Coord', avatar_url: null }] } + }) + const wrapper = mountPage() + await flushPromises() + + const html = wrapper.html() + const order = [ + 'network-show-name', + 'network-groups-moderation"', + 'network-events-moderation"', + 'network-show-impact', + 'network-show-coordinators"', + 'network-show-events', + 'network-groups-map-list', + 'tags-management', + ].map((marker) => [marker, html.indexOf(marker)]) + + order.forEach(([marker, at]) => expect(at, marker).toBeGreaterThan(-1)) + expect(order.map(([, at]) => at)).toEqual([...order.map(([, at]) => at)].sort((a, b) => a - b)) + expect(html.indexOf('About')).toBeGreaterThan(html.indexOf('network-show-impact')) + expect(html.indexOf('About')).toBeLessThan(html.indexOf('network-show-coordinators"')) + }) + + it('renders the network events section for the network', async () => { + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.get('[data-testid="network-show-events"]').text()).toContain('Upcoming events') + expect(wrapper.get('[data-testid="stub-network-events"]').attributes('data-network-id')).toBe('1') + }) + + it('uses the full width for tags (no half-width column)', async () => { + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find('.col-lg-6').exists()).toBe(false) + }) + + describe('impact period', () => { + it('defaults to all time, then fetches and shows the last 12 months with the months covered', async () => { + networksStore.fetchStats = vi.fn().mockResolvedValue({ + parties: 2, + period: { start: '2025-10-01', end: '2026-09-30' }, + }) + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find('[data-testid="period-all"]').classes()).toContain('active') + expect(wrapper.get('[data-testid="stub-network-stats"]').attributes('data-parties')).toBe('3') + expect(wrapper.find('[data-testid="network-show-period-label"]').exists()).toBe(false) + + await wrapper.get('[data-testid="period-last12"]').trigger('click') + await flushPromises() + + expect(networksStore.fetchStats).toHaveBeenCalledWith(1, { period: 'last_12_months' }) + expect(wrapper.get('[data-testid="stub-network-stats"]').attributes('data-parties')).toBe('2') + expect(wrapper.get('[data-testid="network-show-period-label"]').text()).toBe('October 2025 to September 2026') + // The group tally is the current one whichever period is shown. + expect(wrapper.get('[data-testid="stub-network-stats"]').attributes('data-groups-count')).toBe('7') + }) + + it('goes back to all time without refetching, and remembers last 12 months once fetched', async () => { + networksStore.fetchStats = vi.fn().mockResolvedValue({ parties: 2, period: { start: '2025-10-01', end: '2026-09-30' } }) + const wrapper = mountPage() + await flushPromises() + + await wrapper.get('[data-testid="period-last12"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="period-all"]').trigger('click') + await wrapper.get('[data-testid="period-last12"]').trigger('click') + await flushPromises() + + expect(networksStore.fetchStats).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="stub-network-stats"]').attributes('data-parties')).toBe('2') + }) + + it('says so when the last 12 months cannot be loaded', async () => { + networksStore.fetchStats = vi.fn().mockRejectedValue({ status: 500 }) + const wrapper = mountPage() + await flushPromises() + + await wrapper.get('[data-testid="period-last12"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="network-show-period-error"]').exists()).toBe(true) + }) + }) + + describe('coordinators row', () => { + function withCoordinators(n) { + networksStore.fetchCurrent = vi.fn().mockImplementation(async () => { + networksStore.current.data = { + ...NETWORK, + coordinators: Array.from({ length: n }, (_, i) => ({ id: i + 1, name: `Coord ${i + 1}`, avatar_url: null })), + } + }) + } + + it('shows 4 coordinators and a Show N more control for the rest', async () => { + withCoordinators(7) + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.findAll('[data-testid^="network-coordinator-"]')).toHaveLength(4) + const toggle = wrapper.get('[data-testid="network-show-coordinators-toggle"]') + expect(toggle.text()).toBe('Show 3 more') + + await toggle.trigger('click') + expect(wrapper.findAll('[data-testid^="network-coordinator-"]')).toHaveLength(7) + expect(toggle.text()).toBe('Show fewer') + }) + + it('has no toggle when they all fit in the row', async () => { + withCoordinators(4) + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find('[data-testid="network-show-coordinators-toggle"]').exists()).toBe(false) + }) + }) + it('renders the network-scoped groups/events moderation tables, always, with the legacy heading copy', async () => { const wrapper = mountPage() await flushPromises() From 9753b1db9c9919ce048bd8f8c61845db1ea17f51 Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:07:56 +0000 Subject: [PATCH 4/5] Network events: recognise the SPA's sanctum bearer token for include_unapproved Co-Authored-By: Claude Opus 5.5 --- app/Http/Controllers/API/NetworkController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php index f3a226582a..f242eb08ff 100644 --- a/app/Http/Controllers/API/NetworkController.php +++ b/app/Http/Controllers/API/NetworkController.php @@ -394,7 +394,8 @@ public function getNetworkEventsv2(Request $request, $id) // Coordinators of this network (and admins) can ask for events on unapproved and archived groups too, so // that they can spot a group waiting for approval which already has events, or look back at a closed one. - $user = auth('api')->user(); + // The SPA's bearer tokens authenticate via sanctum; third parties use the legacy api_token. + $user = auth('sanctum')->user() ?? auth('api')->user(); $includeUnapproved = $request->boolean('include_unapproved') && $user && ($user->hasRole('Administrator') || $user->isCoordinatorOf($network)); From bd606dc8021e54d830aacbaddd8d5688858f625d Mon Sep 17 00:00:00 2001 From: edwh Date: Tue, 27 Oct 2026 17:50:40 +0000 Subject: [PATCH 5/5] Network events calendar: Escape on the day popover closes it Co-Authored-By: Claude Opus 5.5 --- client/app/components/networks/NetworkEventsCalendar.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/client/app/components/networks/NetworkEventsCalendar.vue b/client/app/components/networks/NetworkEventsCalendar.vue index b922747f0d..b4440bf19c 100644 --- a/client/app/components/networks/NetworkEventsCalendar.vue +++ b/client/app/components/networks/NetworkEventsCalendar.vue @@ -141,6 +141,7 @@ onBeforeUnmount(() => { :aria-label="popoverTitle(d.date)" :data-testid="`network-events-popover-${d.date}`" @click.stop + @keydown.esc.stop="close" >
{{ popoverTitle(d.date) }}