Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions app/Helpers/StatsPeriod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Helpers;

use Carbon\Carbon;

/**
* Date windows for headline stats toggles ("All time" / "Last 12 months"), shared so the Fixometer and group pages
* can offer the same toggle later.
*/
class StatsPeriod
{
public const ALL = 'all';
public const LAST_12_MONTHS = 'last_12_months';

/**
* The window for a period name as [from, to] (UTC, to exclusive), or [null, null] for all time. Last 12 months
* is the last 12 completed calendar months, so the figure doesn't change day by day: on 2026-10-24 it is
* 2025-10-01 to 2026-09-30.
*/
public static function window(?string $period, ?Carbon $now = null): array
{
if ($period !== self::LAST_12_MONTHS) {
return [null, null];
}

$to = ($now ?? Carbon::now('UTC'))->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()];
}
}
56 changes: 45 additions & 11 deletions app/Http/Controllers/API/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -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')) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions app/Http/Resources/GroupSummary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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",
* ),
* )
*/

Expand Down Expand Up @@ -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
];

Expand Down
8 changes: 8 additions & 0 deletions app/Http/Resources/PartySummary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 4 additions & 2 deletions app/Network.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();

Expand Down
119 changes: 119 additions & 0 deletions client/app/components/networks/NetworkEventChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import moment from 'moment-timezone'
import { useUploadedImageUrl } from '~/composables/useUploadedImageUrl.js'
import { groupColour } from '~/utils/networkEvents.js'

// One event as a link-chip in the network calendar and its "+N more"
// popover: start time, the group's logo (when it has one) and the group's
// name, in a colour that is the same for every event of that group.
// Cancelled events are struck through; events on a group that is not
// approved or is archived are dashed (outlined rather than filled).
const props = defineProps({
event: {
type: Object,
required: true,
},
})

const { t } = useI18n()
const { uploadedImageUrl } = useUploadedImageUrl()

const group = computed(() => props.event.group || {})
const unapproved = computed(() => group.value.approved === false)
const archived = computed(() => !!group.value.archived_at)

const time = computed(() => moment.tz(props.event.start, props.event.timezone || 'UTC').format('HH:mm'))
const avatar = computed(() => uploadedImageUrl(group.value.image))

// The chip only has room for the time and group, so say the rest on hover.
const tooltip = computed(() => {
const notes = []

if (props.event.cancelled) notes.push(t('networks.events.cancelled'))
if (unapproved.value) notes.push(t('networks.events.group_unapproved'))
if (archived.value) notes.push(t('networks.events.group_archived'))

return notes.length ? `${props.event.title} (${notes.join(', ')})` : props.event.title
})
</script>

<template>
<NuxtLink
:to="`/party/view/${event.id}`"
class="network-event-chip"
:class="{
cancelled: event.cancelled,
online: event.online,
unapproved,
archived,
dashed: unapproved || archived,
}"
:style="{ '--chip-colour': groupColour(group.id) }"
:title="tooltip"
:data-testid="`network-event-chip-${event.id}`"
>
<img v-if="avatar" :src="avatar" alt="" width="18" height="18" class="chip-avatar">
<span class="chip-time">{{ time }}</span>
<span class="chip-name">{{ group.name }}</span>
</NuxtLink>
</template>

<style scoped lang="scss">
.network-event-chip {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
line-height: 1.3;
padding: 0.2rem 0.4rem;
margin-bottom: 0.2rem;
border-radius: 4px;
border: 1px solid var(--chip-colour);
background: var(--chip-colour);
color: #fff;
white-space: nowrap;
overflow: hidden;
// The site underlines links in tables; a chip is a block, so it doesn't need it.
text-decoration: none !important;

Check warning on line 79 in client/app/components/networks/NetworkEventChip.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "!important" and resolve the specificity conflict instead.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AaF-V3KRWqdZUbXgpjSS&open=AaF-V3KRWqdZUbXgpjSS&pullRequest=952

&:hover {
text-decoration: none;
filter: brightness(1.15);
}

&.cancelled .chip-name,
&.cancelled .chip-time {
text-decoration: line-through;
}

// Not approved / archived: dashed outline on white, so it reads as
// "not a normal event" without relying on colour alone.
&.dashed {
background: #fff;
color: var(--chip-colour);
border-style: dashed;
}
}

.chip-avatar {
flex: none;
width: 18px;
height: 18px;
border-radius: 50%;
object-fit: cover;
background: #fff;
}

.chip-time {
flex: none;
font-weight: 600;
}

.chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
Loading
Loading