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..f242eb08ff 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,17 @@ 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.
+ // 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));
// 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 +424,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 +536,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 +582,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/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 @@
+
+
+
+
+
+ {{ time }}
+ {{ group.name }}
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ t('networks.events.subscribe') }}
+
+
+
+ {{ t('networks.events.calendar') }}
+
+
+ {{ t('networks.events.list') }}
+
+
+
+
+
+
+
{{ t('networks.events.error') }}
+
{{ t('networks.events.loading') }}
+
+
+
+
+ {{ t('networks.events.subscribe_description') }}
+
+
+ {{ t('profile.calendars.copy_link') }}
+
+
+
+
+
+
diff --git a/client/app/components/networks/NetworkEventsCalendar.vue b/client/app/components/networks/NetworkEventsCalendar.vue
new file mode 100644
index 0000000000..b4440bf19c
--- /dev/null
+++ b/client/app/components/networks/NetworkEventsCalendar.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+ ‹
+ {{ t('networks.events.prev_month') }}
+
+
{{ title }}
+
+ ›
+ {{ t('networks.events.next_month') }}
+
+
+
+
+
+ {{ d }}
+
+
+
+
+
+ {{ d.day }}
+
+
+
+
+ {{ t('networks.events.more', { count: dayEvents(d.date).length - MAX_CHIPS }) }}
+
+
+
+ {{ dayEvents(d.date).length }}
+
+
+
+
+
+ {{ popoverTitle(d.date) }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
{{ t('networks.events.empty') }}
+
+
{{ month.label }}
+
+
+
{{ e.localStart.format('D') }}
+
{{ e.localStart.format('MMM') }}
+
+
+
+
{{ e.title }}
+
+ {{ e.localStart.format('ddd') }}
+ {{ e.localStart.format('HH:mm') }} - {{ e.localEnd.format('HH:mm') }}
+ · {{ e.group.name }}
+ {{ t('networks.events.online') }}
+ {{ t('networks.events.cancelled') }}
+ {{ t('networks.events.group_unapproved') }}
+ {{ t('networks.events.group_archived') }}
+
+
+
+
+
+
+
+
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/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 @@
+
+
+
+
+
+ {{ t('networks.stats.all_time') }}
+
+
+ {{ t('networks.stats.last_12_months') }}
+
+
+
+
+
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.moderation.groups_title') }}
+
+
+
+ {{ t('networks.show.none') }}
+
+
+
+
+
+ {{ t('networks.moderation.events_title') }}
+
+
+
+
+
{{ t('networks.general.impact') }}
-
+
+ {{ periodLabel }}
+
+ {{ t('client.networks.stats_unavailable') }}
+
+
-
+
{{ t('networks.general.coordinators') }}
-
+
{{ coordinator.name }}
+
+ {{ showAllCoordinators ? t('networks.show.coordinators_fewer') : t('networks.show.coordinators_more', { count: coordinators.length - COORDINATORS_SHOWN }) }}
+
-
-
-
- {{ t('networks.moderation.groups_title') }}
-
-
-
- {{ t('networks.show.none') }}
-
-
-
-
-
- {{ t('networks.moderation.events_title') }}
-
-
-
+
+ {{ t('networks.events.title') }}
+
+
@@ -259,18 +329,12 @@ function retry() {
-
-
-
-
- {{ t('networks.tags.title') }}
-
-
-
-
-
+
+
+ {{ t('networks.tags.title') }}
+
+
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/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/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/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/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/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()
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/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..3ab1672fe2 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',
@@ -48,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',
@@ -67,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',
@@ -77,4 +86,34 @@
'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.',
+ '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 06db741a0d..9cc6c1b801 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',
@@ -50,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',
@@ -70,5 +75,39 @@
'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',
+ '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.',
+ '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 06db741a0d..9cc6c1b801 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',
@@ -50,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',
@@ -70,5 +75,39 @@
'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',
+ '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.',
+ '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/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));
+ }
+}