Files
timebank-cc-public/app/Http/Livewire/Admin/Stats.php
T

1987 lines
75 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Livewire\Admin;
use App\Helpers\ProfileAuthorizationHelper;
use App\Services\Stats\StatsFilter;
use App\Services\StatsService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\Attributes\Renderless;
use Livewire\Component;
/**
* Platform-wide statistics for Bank and Admin profiles.
*
* Note this deliberately does NOT use RequiresAdminAuthorization::authorizeAdminAccess():
* that helper rejects banks whose level !== 0, but Stats is open to every bank
* holding the permission. Authorization is a permission check instead, repeated
* on every public method — Livewire methods are callable directly after mount,
* so mount()-only protection would be bypassable.
*/
class Stats extends Component
{
// Period
public ?string $fromDate = null;
public ?string $toDate = null;
/**
* "All time" selected deliberately, as opposed to no period chosen yet.
* Both leave the dates null, but only one should run the statistics.
*/
public bool $allTime = false;
// Location filter
public bool $filterByLocation = false;
public array $selectedCountryIds = [];
public array $selectedDivisionIds = [];
public array $selectedCityIds = [];
public array $selectedDistrictIds = [];
// Registration-date filter
public bool $filterByRegistration = false;
public ?string $registeredFrom = null;
public ?string $registeredTill = null;
// Counterparty filter
public bool $filterByContacts = false;
public array $selectedContactProfiles = [];
// Inactivity filters: profiles that have not logged in or traded for a
// while. Both are an upper bound ("since"), not a range.
public bool $filterByLastLogin = false;
public string $lastLoginPeriod = 'since_one_year_ago';
public ?string $lastLoginCustom = null;
public bool $filterByLastTransaction = false;
public string $lastTransactionPeriod = 'since_one_year_ago';
public ?string $lastTransactionCustom = null;
public bool $decimalFormat = false;
/**
* Replace profile names with stable numbers in the most-active table.
*
* On by default: this page is the one place a bank or admin sees every
* member's activity side by side, and the export is made to be shared, so
* names are revealed deliberately rather than by omission.
*/
public bool $anonymizeProfiles = true;
// Sort for the "most active profiles" table.
public string $topSort = 'count';
public string $topDirection = 'desc';
// The location table sorts independently of the profiles table.
public string $locationSort = 'count';
public string $locationDirection = 'desc';
/** Set by refreshStats() to bypass the result cache on the next render. */
public bool $fresh = false;
// --- Trading network graph ---
/**
* The section is closed by default: the graph costs an extra aggregation
* and ~570 kB of lazily loaded JavaScript, and most visits to this page are
* about the tables.
*/
public bool $showNetwork = false;
/**
* Minimum number of trading partners.
*
* Zero by default: a threshold silently dropped profiles that the stats
* tables on the same page still listed, so the two disagreed about who was
* in the selection with nothing on screen to explain it. The graph is
* denser this way, and the slider raises the floor when that matters more
* than completeness.
*/
public int $networkMinDegree = 0;
/**
* Upper bound on the number of trading partners, to leave the hubs out.
* The top stop (DEGREE_CEILING) means "no bound".
*/
public int $networkMaxDegree = StatsService::DEGREE_CEILING;
/**
* Hide the single best-connected node.
*
* Off by default: the graph should show the whole network unless the user
* asks otherwise. The toggle stays because the hub really does dominate -
* on dev Lekkernassuh has 708 partners against a median of 1, which pulls
* the layout into a star - but that is a choice to make while looking at
* it, not one to impose before the first render.
*/
public bool $networkHideHub = false;
/**
* Also show edges with one end outside the scope. Off by default: the safe
* setting. Outsiders always appear without a name.
*/
public bool $networkEdge = false;
/** circulation | volume | flow | cluster | region — what colour and size encode. */
public string $networkView = 'circulation';
/**
* Which profile types take part in the graph.
*
* Banks included: without them currency creation and removal have no
* counterparty to draw to, so those flows are invisible. Measured, they
* add 140 nodes and do not displace the hub - the earlier worry that a
* bank would crowd out mutual trade did not bear out once the degree
* slider and the hide-hub toggle existed.
*/
public array $networkTypes = ['User', 'Organization', 'Bank'];
/**
* Arrangement of the nodes. Separate from $networkView: that decides colour
* and size (what you see), this decides placement (how it is laid out).
*/
public string $networkLayout = 'force';
/**
* A period for the network graph alone.
*
* 'inherit' follows the page filter; any other value overrides this section
* only. Deliberately separate from $fromDate/$toDate so the tables and
* charts above stay untouched — this is a quick exploration of the network,
* not a change to the whole page.
*/
public string $networkPeriod = 'all_time';
/**
* Include profiles closed by their owner.
*
* On by default: currency removals are balance transfers made during
* closure, so without this the hours that left the network are invisible.
* Removed profiles are drawn hollow, with the same outline as any other.
*/
public bool $networkIncludeRemoved = true;
/**
* Highlight profiles marked inactive with a red outline.
*
* Not an inclusion toggle: inactive profiles are always in the graph,
* because notRemoved() only tests deleted_at. This makes them visible.
* On by default, so dormant profiles are apparent without hunting.
*/
public bool $networkMarkInactive = true;
/**
* Time-lapse: step the graph through one year at a time.
*
* Off by default - it is an exploration mode, and turning it on changes
* what the period selector means.
*/
public bool $networkTimelapse = false;
/** The frame currently on screen, as 'YYYY-MM'. */
public ?string $networkTimelapseMonth = null;
/**
* How many months the animation spans, counting back from the end date.
*
* Up to ten years. All frames come from one grouped query (~1s on dev for
* any span), and travel as changes per frame (networkTimelapse()), so the
* download no longer grows with the span the way full frames did - 13 MB
* for ten accumulated years before, about one graph's worth now. The cap
* stays as a guard on the server's month walk.
*/
public const TIMELAPSE_MAX_MONTHS = 120;
/**
* Span value meaning "from the very first trade": no cap. Ten years ran
* well (2.3 MB for weekly steps, 94 MB PHP peak), and all of history is
* only a few years more.
*/
public const TIMELAPSE_ALL_TIME = 0;
public int $networkTimelapseSpan = 36;
/**
* On (default): every frame shows everything traded up to its month, so
* the network builds up. Off: each frame shows only its own month's trade
* - a window exactly one frame long - so it answers "who was trading
* then" and profiles come and go.
*/
public bool $networkTimelapseCumulative = true;
/**
* How far each frame moves on: 'month' or 'week'. The layers stay months
* either way - this month, the month before, everything older - so a
* weekly time-lapse looks the same, only moving in smaller steps.
*/
public string $networkTimelapseStep = 'month';
/**
* The date the time-lapse ends on, 'Y-m-d'. Null until the time-lapse is
* first switched on, when it is filled with the last date anyone traded.
*
* Replaces the graph-period dropdown while the time-lapse is on: every
* frame accumulates from the very first trade, so a period has nothing to
* select - the only free choice is where the animation stops. Span then
* counts back from here.
*/
public ?string $networkTimelapseEnd = null;
/**
* Months after which a profile counts as having stopped circulating.
*
* Only the circulation view uses it. Pure topology says a node circulates
* if it sits in a cycle, with no sense of when - so a profile that passed
* hours on years ago still read as circulating. 25 months by default:
* long enough that a profile trading once every year or two is not called
* stopped, short enough to still separate the dormant majority.
*
* The top stop means NO date limit, like the degree slider's ceiling: the
* data runs back 162 months, so 60 would otherwise be an arbitrary cut
* rather than "however long ago". At that setting only the inactive flag
* still marks a profile as stopped.
*/
public const STOPPED_AFTER_CEILING = 60;
public int $networkStoppedAfterMonths = 25;
/**
* The shared select-period component dispatches a default period on mount,
* so a period alone cannot mean "the user chose something". Statistics stay
* hidden until an explicit action — running them, or setting a filter —
* which keeps a platform-wide query off the initial page load.
*/
public bool $showResults = false;
protected $listeners = [
'periodSelected' => 'periodSelectedDispatched',
'periodCleared' => 'periodClearedDispatched',
];
public function mount(): void
{
$this->authorizeStatsAccess();
}
/**
* Allow Admin and Bank profiles holding the stats permission. Called at the
* top of every public entry point, not just mount().
*/
protected function authorizeStatsAccess(): void
{
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403, __('No active profile selected'));
}
$profile = $activeProfileType::find($activeProfileId);
if (!$profile) {
abort(403, __('Active profile not found'));
}
// Prevents cross-guard access via session manipulation.
ProfileAuthorizationHelper::authorize($profile);
if (!($profile instanceof \App\Models\Admin) && !($profile instanceof \App\Models\Bank)) {
abort(403, __('Admin or bank access required'));
}
if (!Gate::allows('access stats') && !Gate::allows('org stats')) {
Log::warning('Stats: permission denied', [
'profile_type' => get_class($profile),
'profile_id' => $profile->id,
'ip_address' => request()->ip(),
]);
abort(403, __('You do not have permission to view statistics.'));
}
}
// ------------------------------------------------------------------ filters
public function periodSelectedDispatched($periodData): void
{
$this->authorizeStatsAccess();
$from = $periodData['fromDate'] ?? null;
$to = $periodData['toDate'] ?? null;
// Swap inverted ranges rather than silently returning nothing.
if ($from && $to && \Carbon\Carbon::parse($from)->gt(\Carbon\Carbon::parse($to))) {
[$from, $to] = [$to, $from];
}
// "All time" has no dates, so it is indistinguishable from "nothing
// chosen" by the dates alone — the period name is what separates them.
$this->allTime = ($periodData['period'] ?? null) === 'all_time';
// On 'all_time' SelectPeriod still sends the dates of the PREVIOUS
// choice: setPeriodDates() deliberately leaves customFromDate/
// customToDate untouched, but dispatchDates() sends them anyway.
// Ignore those, or "all time" silently gets a one-year window.
$this->fromDate = $this->allTime ? null : $from;
$this->toDate = $this->allTime ? null : $to;
// Period changed: the visible results no longer match the selection,
// so collapse until the user recalculates.
$this->closeResultSections();
}
public function periodClearedDispatched(): void
{
$this->authorizeStatsAccess();
$this->fromDate = null;
$this->toDate = null;
$this->allTime = false;
$this->closeResultSections();
}
#[On('locationFilterUpdated')]
public function handleLocationFilterUpdate($locationData): void
{
$this->authorizeStatsAccess();
$this->selectedCountryIds = $locationData['countries'] ?? [];
$this->selectedDivisionIds = $locationData['divisions'] ?? [];
$this->selectedCityIds = $locationData['cities'] ?? [];
$this->selectedDistrictIds = $locationData['districts'] ?? [];
$this->closeResultSections();
}
public function updatedFilterByLocation(): void
{
$this->authorizeStatsAccess();
if (!$this->filterByLocation) {
$this->selectedCountryIds = [];
$this->selectedDivisionIds = [];
$this->selectedCityIds = [];
$this->selectedDistrictIds = [];
$this->dispatch('resetLocationFilter');
}
$this->closeResultSections();
}
public function updatedFilterByLastLogin(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedLastLoginPeriod(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedLastLoginCustom(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedFilterByLastTransaction(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedLastTransactionPeriod(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedLastTransactionCustom(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
/**
* Cut-off date for the inactivity filters.
*
* The same periods as elsewhere on the page, but as a single date: "not
* logged in since X". 'all_time' yields null, which means no bound — the
* filter then removes nothing.
*/
private function inactivityCutoff(string $period, ?string $custom): ?\Carbon\Carbon
{
switch ($period) {
case 'since_one_year_ago':
return \Carbon\Carbon::now()->subYear();
case 'previous_year':
return \Carbon\Carbon::now()->subYear()->startOfYear();
case 'previous_quarter':
return \Carbon\Carbon::now()->subQuarter()->startOfQuarter();
case 'previous_month':
return \Carbon\Carbon::now()->subMonth()->startOfMonth();
case 'custom':
return $custom ? \Carbon\Carbon::parse($custom) : null;
case 'all_time':
default:
return null;
}
}
public function updatedFilterByRegistration(): void
{
$this->authorizeStatsAccess();
if (!$this->filterByRegistration) {
$this->registeredFrom = null;
$this->registeredTill = null;
}
$this->closeResultSections();
}
public function updatedRegisteredFrom(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
public function updatedRegisteredTill(): void
{
$this->authorizeStatsAccess();
$this->closeResultSections();
}
#[On('statsSelectedProfiles')]
public function handleSelectedContactProfiles(array $profiles = []): void
{
$this->authorizeStatsAccess();
if (empty($profiles)) {
return;
}
// Ids collide across profile tables, so dedupe on the (id, type) pair.
$alreadySelected = collect($this->selectedContactProfiles)->contains(
fn($p) => $p['id'] === $profiles['id'] && $p['type'] === $profiles['type']
);
if (!$alreadySelected) {
$this->selectedContactProfiles[] = $profiles;
}
$this->closeResultSections();
}
public function removeContactProfile($index): void
{
$this->authorizeStatsAccess();
unset($this->selectedContactProfiles[$index]);
$this->selectedContactProfiles = array_values($this->selectedContactProfiles);
$this->closeResultSections();
}
public function updatedFilterByContacts(): void
{
$this->authorizeStatsAccess();
if (!$this->filterByContacts) {
$this->selectedContactProfiles = [];
}
$this->closeResultSections();
}
public function clearFilters(): void
{
$this->authorizeStatsAccess();
$this->reset([
'fromDate', 'toDate', 'allTime',
'filterByLocation', 'selectedCountryIds', 'selectedDivisionIds', 'selectedCityIds', 'selectedDistrictIds',
'filterByRegistration', 'registeredFrom', 'registeredTill',
'filterByContacts', 'selectedContactProfiles',
'filterByLastLogin', 'lastLoginPeriod', 'lastLoginCustom',
'filterByLastTransaction', 'lastTransactionPeriod', 'lastTransactionCustom',
]);
$this->closeResultSections();
$this->dispatch('resetLocationFilter');
$this->dispatch('periodCleared');
}
public function toggleNetwork(): void
{
$this->authorizeStatsAccess();
$this->showNetwork = !$this->showNetwork;
}
/**
* Lower and upper bound of the degree slider.
*
* They must not cross: that produces an empty graph without the user seeing
* why. The other bound moves along instead of blocking the change.
*/
public function setNetworkDegreeRange(int $min, int $max): void
{
$this->authorizeStatsAccess();
$ceiling = StatsService::DEGREE_CEILING;
$this->networkMinDegree = max(0, min($ceiling, $min));
$this->networkMaxDegree = max(0, min($ceiling, $max));
if ($this->networkMinDegree > $this->networkMaxDegree) {
$this->networkMaxDegree = $this->networkMinDegree;
}
}
/**
* wire:model writes straight to the property, so the clamping in
* setNetworkDegreeRange() would be skipped. These hooks apply it anyway —
* otherwise the user can drag min past max and gets an empty graph with no
* explanation.
*/
public function updatedNetworkMinDegree($value): void
{
$this->setNetworkDegreeRange((int) $value, $this->networkMaxDegree);
}
public function updatedNetworkMaxDegree($value): void
{
// Upper bound dragged below the lower one: the lower bound follows,
// rather than refusing the change.
$max = max(0, min(StatsService::DEGREE_CEILING, (int) $value));
if ($max < $this->networkMinDegree) {
$this->networkMinDegree = $max;
}
$this->networkMaxDegree = $max;
}
public function setNetworkMinDegree(int $degree): void
{
$this->setNetworkDegreeRange($degree, $this->networkMaxDegree);
}
public function setNetworkMaxDegree(int $degree): void
{
$this->setNetworkDegreeRange($this->networkMinDegree, $degree);
}
public function toggleNetworkHub(): void
{
$this->authorizeStatsAccess();
$this->networkHideHub = !$this->networkHideHub;
}
public function toggleNetworkEdge(): void
{
$this->authorizeStatsAccess();
$this->networkEdge = !$this->networkEdge;
}
public function toggleNetworkType(string $type): void
{
$this->authorizeStatsAccess();
// Allow-list: the value filters server-side, so never take it blindly.
if (!in_array($type, ['User', 'Organization', 'Bank'], true)) {
return;
}
if (in_array($type, $this->networkTypes, true)) {
$this->networkTypes = array_values(array_diff($this->networkTypes, [$type]));
} else {
$this->networkTypes[] = $type;
}
}
/** Periods the graph can show independently of the page. */
public const NETWORK_PERIODS = [
'inherit', 'since_one_year_ago', 'previous_year',
'previous_quarter', 'previous_month', 'all_time',
];
public function setNetworkPeriod(string $period): void
{
$this->authorizeStatsAccess();
if (in_array($period, self::NETWORK_PERIODS, true)) {
$this->networkPeriod = $period;
}
}
/**
* The filter for the graph: the page filter, or its own period on top.
*
* The same calculations as SelectPeriod::setPeriodDates(), so 'previous
* quarter' means the same here as it does there.
*/
private function networkFilter(StatsFilter $base): StatsFilter
{
if ($this->networkPeriod === 'inherit') {
return $base;
}
$from = null;
$till = null;
switch ($this->networkPeriod) {
case 'since_one_year_ago':
$from = \Carbon\Carbon::now()->subYear();
$till = \Carbon\Carbon::now();
break;
case 'previous_year':
$from = \Carbon\Carbon::now()->subYear()->startOfYear();
$till = \Carbon\Carbon::now()->subYear()->endOfYear();
break;
case 'previous_quarter':
$from = \Carbon\Carbon::now()->subQuarter()->startOfQuarter();
$till = \Carbon\Carbon::now()->subQuarter()->endOfQuarter();
break;
case 'previous_month':
$from = \Carbon\Carbon::now()->subMonth()->startOfMonth();
$till = \Carbon\Carbon::now()->subMonth()->endOfMonth();
break;
case 'all_time':
// No bounds; the other filters still apply.
break;
}
return new StatsFilter(
periodFrom: $from,
periodTill: $till,
countryIds: $base->countryIds,
divisionIds: $base->divisionIds,
cityIds: $base->cityIds,
districtIds: $base->districtIds,
registeredFrom: $base->registeredFrom,
registeredTill: $base->registeredTill,
counterparties: $base->counterparties,
// Carry the sort fields, so the graph's cache key cannot collide
// with that of a different sort order.
topSort: $base->topSort,
topDirection: $base->topDirection,
locationSort: $base->locationSort,
locationDirection: $base->locationDirection,
allTime: $this->networkPeriod === 'all_time',
notLoggedInSince: $base->notLoggedInSince,
noTransactionSince: $base->noTransactionSince,
includeRemoved: $this->networkIncludeRemoved,
markInactive: $this->networkMarkInactive,
);
}
/**
* The four graph views, by key.
*
* Static and shared with SingleReport so the bank report cannot drift from
* the admin stats page.
*
* @return array<string,string>
*/
public static function networkViewOptions(): array
{
// Circulation first: it answers the question this page exists for -
// whether hours move or pile up - and is the default view.
return [
'circulation' => __('Circulation'),
'volume' => __('Volume'),
'flow' => __('Currency flow'),
'cluster' => __('Clusters'),
'region' => __('Regions'),
];
}
/**
* The question a view answers, as a caption inside the graph frame.
*
* Deliberately not a restatement of networkViewHint(): that says what the
* colours and sizes encode, this says what the view is for. A reader
* switching between five views needs the purpose at a glance, and the
* legend below the frame is too far away to serve that.
*/
public static function networkViewCaption(string $view): string
{
return match ($view) {
// A question like the other views have, in the platform's own
// currency name. The explanation that used to stand here is now
// the legend's (networkViewHint), on the page and in the exports.
'flow' => str_replace(
'@PLATFORM_CURRENCY_NAME_PLURAL@',
self::currencyMidSentence(),
__('Which way do the @PLATFORM_CURRENCY_NAME_PLURAL@ flow?')
),
'cluster' => __('Who trades mainly with whom?'),
'region' => __('Where are the profiles based?'),
'circulation' => __('Where does the currency stop moving?'),
default => __('Where is the economic weight?'),
};
}
/**
* The platform's currency name (plural) as it reads inside a sentence.
*
* Configured capitalised ("Hours", "Uren"), which is right on its own but
* wrong mid-sentence - "Which way do the Hours flow?" - except in German,
* where every noun is capitalised anyway ("Stunden").
*/
private static function currencyMidSentence(): string
{
$name = platform_currency_name_plural();
return app()->getLocale() === 'de' ? $name : mb_strtolower($name);
}
/**
* One sentence explaining what a view encodes.
*
* Volume and flow differ in two ways at once — node size and edge meaning —
* which is not guessable from the graph itself, so the legend spells it out.
*/
public static function networkViewHint(string $view): string
{
return match ($view) {
// In the platform's own currency name, like the view's title.
'flow' => str_replace(
'@PLATFORM_CURRENCY_NAME_PLURAL@',
self::currencyMidSentence(),
__('Dot size is the number of trading partners. Every arrow runs from payer to receiver, coloured by transaction type, its width showing the @PLATFORM_CURRENCY_NAME_PLURAL@ of that type that flowed that way. A pair trading two types gets an arrow for each.')
),
'cluster' => __('Groups of profiles that mainly trade with each other get their own colour. Dot size is the number of trading partners.'),
'region' => __('Colour is the location of the profile. Dot size is the number of trading partners.'),
'circulation' => __('Blue where currency keeps moving: the profile paid someone within the window. Purple is where it stalls - hours arrived and have not been passed on. Cyan never passed hours on at all. Use the slider to set how long counts as stopped. Dot size is the number of trading partners.'),
default => __('Dot size is the total time a profile traded. Each relationship is one line, with the width showing how often the two traded, not how many hours.'),
};
}
/**
* The graph's settings in words, for the PDF.
*
* A printed graph carries no controls, so without this a reader cannot tell
* which view or thresholds produced it.
*
* @return array<array{label:string,value:string}>
*/
public function networkSettingsSummary(): array
{
$views = self::networkViewOptions();
$layouts = [
'force' => __('Force-directed'),
'circle' => __('Circle'),
'concentric' => __('Rings by connections'),
'tree' => __('Tree'),
'grid' => __('Grid'),
];
$ceiling = StatsService::DEGREE_CEILING;
$types = array_map(
fn($t) => ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')][$t] ?? $t,
$this->networkTypes
);
$summary = [
['label' => __('View'), 'value' => $views[$this->networkView] ?? $this->networkView],
['label' => __('Arrangement'), 'value' => $layouts[$this->networkLayout] ?? $this->networkLayout],
['label' => __('Trading partners'), 'value' => $this->networkMinDegree . ' - '
. ($this->networkMaxDegree >= $ceiling ? '>' . $ceiling : $this->networkMaxDegree)],
['label' => __('Show'), 'value' => implode(', ', $types) ?: __('None')],
];
if ($this->networkPeriod !== 'inherit') {
$summary[] = ['label' => __('Period'), 'value' => $this->networkPeriodLabel()];
}
if ($this->networkView === 'circulation') {
$summary[] = [
'label' => __('Define \'stopped\' at'),
'value' => ($this->networkStoppedAfterMonths >= self::STOPPED_AFTER_CEILING
? '>' . self::STOPPED_AFTER_CEILING
: $this->networkStoppedAfterMonths) . ' ' . __('months'),
];
}
if ($this->networkHideHub) {
$summary[] = ['label' => __('Hide the largest hub'), 'value' => __('Yes')];
}
// Stated explicitly: a reader of a shared export should not have to
// guess whether "User 12" is a pseudonym or someone's actual name.
if ($this->anonymizeProfiles) {
$summary[] = ['label' => __('Anonymise profile names'), 'value' => __('Yes')];
}
if ($this->networkIncludeRemoved) {
$summary[] = ['label' => __('Include removed profiles'), 'value' => __('Yes')];
}
if ($this->networkMarkInactive) {
$summary[] = ['label' => __('Highlight inactive profiles'), 'value' => __('Yes')];
}
return $summary;
}
/** Readable name of the graph's own period, shown under the heading. */
public function networkPeriodLabel(): string
{
return [
'since_one_year_ago' => __('Since one year ago'),
'previous_year' => __('Previous year'),
'previous_quarter' => __('Previous quarter'),
'previous_month' => __('Previous month'),
'all_time' => __('All time'),
][$this->networkPeriod] ?? '';
}
/**
* The period the graph is actually drawn over, in words.
*
* networkPeriodLabel() only describes the graph's own override and returns
* an empty string on 'inherit'. This resolves that case to the page filter,
* so an exported figure always states its period - a graph without one
* cannot be read once it leaves the screen.
*/
public function effectiveNetworkPeriodLabel(): string
{
if ($this->networkPeriod !== 'inherit') {
return $this->networkPeriodLabel();
}
if ($this->fromDate && $this->toDate) {
return \Carbon\Carbon::parse($this->fromDate)->format('d-m-Y')
. ' – ' . \Carbon\Carbon::parse($this->toDate)->format('d-m-Y');
}
return __('All time');
}
/** Clamped to the slider's own range; the top stop means no limit. */
public function setNetworkStoppedAfter(int $months): void
{
$this->authorizeStatsAccess();
$this->networkStoppedAfterMonths = max(1, min(self::STOPPED_AFTER_CEILING, $months));
}
public function setNetworkLayout(string $layout): void
{
$this->authorizeStatsAccess();
// Allow-list: the value picks a layout in JS, so never take it blindly.
if (in_array($layout, ['force', 'circle', 'concentric', 'tree', 'grid'], true)) {
$this->networkLayout = $layout;
}
}
/**
* One time-lapse frame, fetched on demand.
*
* Deliberately NOT all frames at once: measured at 407 KB of JSON for four
* years, so the full range would be ~1.4 MB in one response. Each frame is
* cached on its own argument set, so stepping back over a year already
* seen costs ~6ms.
*/
/**
* The months the time-lapse steps through, newest last.
*
* Bounded by $networkTimelapseSpan rather than by all of history. A
* monthly step over the full 2013-2026 range is 165 frames, 14.8 MB and
* ~77s cold - fine for an export, far too slow to fetch live.
*
* @return array<array{key:string, label:string}>
*/
public function networkTimelapseMonths(): array
{
$this->authorizeStatsAccess();
$end = $this->timelapseEndDate();
if (!$end) {
return [];
}
if ($this->networkTimelapseSpan === self::TIMELAPSE_ALL_TIME) {
[$first] = $this->tradeRange();
$cursor = ($first ?: $end)->copy()->startOfMonth();
} else {
$months = max(1, min($this->networkTimelapseSpan, self::TIMELAPSE_MAX_MONTHS));
$cursor = $end->copy()->startOfMonth()->subMonths($months - 1);
}
$frames = [];
while ($cursor->lte($end)) {
$frames[] = [
'key' => $cursor->format('Y-m'),
'label' => $cursor->format('M Y'),
];
$cursor->addMonth();
}
return $frames;
}
/**
* First and last moment anyone traded, cached: both bound the end date.
*
* @return array{0: ?Carbon, 1: ?Carbon}
*/
protected function tradeRange(): array
{
$range = \Illuminate\Support\Facades\Cache::remember(
'stats:net:trade-range',
900,
fn () => \Illuminate\Support\Facades\DB::table('transactions')
->selectRaw('MIN(created_at) AS first, MAX(created_at) AS last')
->first()
);
return [
$range && $range->first ? Carbon::parse($range->first) : null,
$range && $range->last ? Carbon::parse($range->last) : null,
];
}
/**
* The end date the time-lapse actually uses: the chosen one, validated and
* clamped to the span of real trade.
*
* The value comes straight from the date input and reaches a query, so it
* is parsed strictly rather than trusted. Anything unparseable, or no
* choice at all, means the last trade date - the latest possible picture.
*/
public function timelapseEndDate(): ?Carbon
{
[$first, $last] = $this->tradeRange();
if (!$last) {
return null;
}
$chosen = null;
if (is_string($this->networkTimelapseEnd)
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $this->networkTimelapseEnd)) {
try {
$chosen = Carbon::createFromFormat('!Y-m-d', $this->networkTimelapseEnd)->endOfDay();
} catch (\Throwable $e) {
$chosen = null;
}
}
if (!$chosen || $chosen->gt($last)) {
return $last->copy()->endOfDay();
}
if ($first && $chosen->lt($first)) {
return $first->copy()->endOfDay();
}
return $chosen;
}
/** Fill the end date the first time the time-lapse is switched on. */
public function updatedNetworkTimelapse($on): void
{
if ($on && $this->networkTimelapseEnd === null) {
$end = $this->timelapseEndDate();
$this->networkTimelapseEnd = $end?->toDateString();
}
}
/** Snap an out-of-range or malformed date back to the one actually used. */
public function updatedNetworkTimelapseEnd(): void
{
$this->networkTimelapseEnd = $this->timelapseEndDate()?->toDateString();
}
/**
* One time-lapse frame, fetched on demand.
*
* Deliberately NOT all frames at once: each is ~90-110 KB, so a 36-frame
* span would be ~3 MB in a single response. Each frame is cached on its
* own argument set, so replaying a month already seen costs ~6ms.
*
* @param string $month 'YYYY-MM'. The END of this frame's window.
*/
/**
* The whole time-lapse in one call: the graph plus a membership list per
* month.
*
* Deliberately NOT one call per frame. Every frame is cumulative, so each
* one was a full all-time query - ~1.1s each, 42.5s for a 36-month span
* before anything appeared. Built in a single pass it is ~1.8s cold.
*
* RENDERLESS because it only reads. A normal Livewire call re-renders the
* component afterwards, the morph that follows fires the graph's rebuild
* hook, and the rebuild calls this again - switching the time-lapse on
* redrew the graph over and over.
*
* NAMED ...Data() because $networkTimelapse is a public property: Livewire
* cannot dispatch a method call that collides with a property name, and the
* call fails silently - the graph draws, the frames never arrive, and the
* play button spins for ever.
*/
#[Renderless]
public function networkTimelapseData(): array
{
$this->authorizeStatsAccess();
$months = $this->networkTimelapseMonths();
if (empty($months)) {
return ['graph' => ['nodes' => [], 'edges' => [], 'stats' => []], 'steps' => [],
'frames' => [], 'ghosts' => null, 'history' => null];
}
$first = Carbon::createFromFormat('Y-m', $months[0]['key'])->startOfMonth();
// The chosen END DATE, not the end of its month: picking 15 June has
// to stop the network on 15 June.
$last = $this->timelapseEndDate();
// Already as changes per step: see StatsService::networkTimelapse().
return (new StatsService($this->networkFilter($this->buildFilter())))
->networkTimelapse(
$first,
$last,
$this->networkTimelapseStep,
$this->networkTimelapseCumulative,
$this->networkMinDegree,
$this->networkScope(),
$this->networkEdge,
$this->networkTypes,
$this->networkMaxDegree,
$this->anonymizeProfiles
);
}
/** Allow-list: the value reaches the server's step walk. */
public function updatedNetworkTimelapseStep($value): void
{
$this->networkTimelapseStep = $value === 'week' ? 'week' : 'month';
}
/**
* The period line of an exported graph.
*
* "All time" says nothing about WHEN the figure was made - read later, or
* printed, it could be any year. So it becomes the date of the most recent
* trade actually drawn: "Up to 22-09-2026". Other periods already name
* their span and are left alone.
*
* Shared by the stats page and the bank report, which each resolve their
* own period label; both return exactly __('All time') for that case.
*/
public static function exportPeriodLabel(string $periodLabel, ?string $lastDate): string
{
if ($periodLabel !== __('All time') || !$lastDate) {
return $periodLabel;
}
return __('Up to :date', ['date' => Carbon::parse($lastDate)->format('d-m-Y')]);
}
/**
* The platform logo for the SVG export: the theme's own inline SVG and the
* colour it is drawn in.
*
* From the theme rather than any fixed file, so every white-label
* installation exports its own logo (config/themes.php, logos.svg_inline).
* The view colours itself with a CSS class, which a standalone SVG file
* has no stylesheet for, so the colour travels separately and the export
* sets it as a fill. Null when the theme has no logo view: an export must
* never fail over its decoration.
*
* @return array{svg: string, color: string}|null
*/
public static function networkExportLogo(): ?array
{
try {
$view = theme_logo('svg_inline');
if (!$view || !view()->exists($view)) {
return null;
}
return [
'svg' => view($view)->render(),
'color' => theme_color('logo') ?: '#000000',
];
} catch (\Throwable $e) {
return null;
}
}
/**
* Full layers again from a stream of changes, as the browser rebuilds them
* in network-timelapse.js. Exists so the two can be tested against each
* other and against direct computation.
*/
public static function decodeFrameDeltas(array $frames): array
{
$nodes = [];
$edges = [];
$out = [];
foreach ($frames as $frame) {
foreach ($frame['nodes_removed'] as $id) {
unset($nodes[$id]);
}
foreach ($frame['nodes_added'] as $id) {
$nodes[$id] = true;
}
foreach ($frame['edges_removed'] as $key) {
unset($edges[$key]);
}
foreach ($frame['edges_added'] as $key) {
$edges[$key] = true;
}
// Ghost and history streams carry no label or date of their own.
$out[] = [
'label' => $frame['label'] ?? null,
'till' => $frame['till'] ?? null,
'nodes' => array_keys($nodes),
'edges' => array_keys($edges),
];
}
return $out;
}
public function setNetworkView(string $view): void
{
$this->authorizeStatsAccess();
// Allow-list: the value drives styling in JS, so never take it blindly.
if (in_array($view, ['flow', 'volume', 'cluster', 'region', 'circulation'], true)) {
$this->networkView = $view;
}
}
/**
* Which profiles the graph is limited to.
*
* null for an Admin (the whole platform). For a Bank: its clients from
* bank_clients — note that bank_user holds the MANAGERS, not the clients.
*
* Determined server-side and not settable from the view: this is the
* boundary that stops a bank seeing names of non-client members.
*/
/**
* Legend for the network graph: what colour, size and line width mean.
*
* Different per view — under 'region' colour encodes the region, elsewhere
* the profile type — so this does not belong as fixed text in the Blade.
* The colour codes mirror TYPE_COLORS / REGION_COLORS in stats-network.js;
* a change must be made in both places.
*
* @param array $network Result of StatsService::network()
*/
public function networkLegend(array $network): array
{
$typeColors = [
'User' => '#14b8a6',
'Organization' => '#d946ef',
'Bank' => '#f59e0b',
];
$regionColors = ['#3b82f6', '#f97316', '#14b8a6', '#d946ef', '#84cc16',
'#8b5cf6', '#ec4899', '#22c55e', '#0ea5e9', '#eab308'];
$nodes = $network['nodes'] ?? [];
// Kleur
if ($this->networkView === 'region') {
// Order of first appearance, matching regionPalette() in JS.
$seen = [];
foreach ($nodes as $node) {
$region = $node['region'] ?? null;
if ($region && !in_array($region, $seen, true)) {
$seen[] = $region;
}
}
$colors = [];
foreach ($seen as $i => $region) {
$colors[] = ['color' => $regionColors[$i % count($regionColors)], 'label' => $region];
}
$colorMeaning = __('Region');
} elseif ($this->networkView === 'circulation') {
// Classification happens in the browser (it depends on the visible
// set, after the hub and the degree filters), so this only explains
// the fixed palette. Keep in step with FLOW_COLORS in
// stats-network.js.
$colors = [
['color' => '#3b82f6', 'label' => __('Passing hours on')],
['color' => '#9333ea', 'label' => __('Stalled: hours are sitting still here')],
['color' => '#22d3ee', 'label' => __('Never passed hours on')],
];
$colorMeaning = __('What the profile does with the hours it sees');
} elseif ($this->networkView === 'cluster') {
// Clusters are computed in the browser, so no names here — only an
// explanation of what the colours mean.
$colors = [];
$colorMeaning = __('Cluster of profiles that mainly trade with each other');
} else {
$present = array_unique(array_column($nodes, 'type'));
$labels = ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')];
$colors = [];
foreach ($typeColors as $type => $hex) {
if (in_array($type, $present, true)) {
$colors[] = ['color' => $hex, 'label' => $labels[$type] ?? $type];
}
}
$colorMeaning = __('Profile type');
}
// Outsiders are always grey, whatever the view.
if (collect($nodes)->contains(fn($n) => $n['outside'] ?? false)) {
$colors[] = ['color' => '#94a3b8', 'label' => __('Another bank network'), 'dashed' => true];
}
return [
'view_label' => $this->networkViewOptions()[$this->networkView] ?? $this->networkView,
'view_caption' => self::networkViewCaption($this->networkView),
'view_hint' => self::networkViewHint($this->networkView),
// In the flow view colour carries two different things: the node
// fill is the profile type, while the edge colour is the
// transaction type. Saying only "Profile type" left the arrow
// colours unexplained.
'color_meaning' => $this->networkView === 'flow'
? $colorMeaning . ' ' . __('(dots); transaction type (arrows)')
: $colorMeaning,
'colors' => $colors,
// Node outline styles, listed only when the toggle that produces
// them is on - an entry for something not drawn is noise.
'node_styles' => array_values(array_filter([
$this->networkIncludeRemoved ? [
// The graph's default node border, so the swatch matches
// what is drawn: same outline as any profile, no fill.
'color' => 'rgba(0,0,0,0.25)',
'label' => __('Removed profile'),
'dashed' => false,
'hollow' => true,
] : null,
$this->networkMarkInactive ? [
// Red, matching node.inactive in stats-network.js.
'color' => '#dc2626',
'label' => __('Marked inactive'),
'dashed' => false,
'hollow' => false,
] : null,
])),
'size_meaning' => $this->networkView === 'volume'
? __('Total time traded by this profile')
: __('Number of trading partners'),
'width_meaning' => $this->networkView === 'flow'
? __('Time traded between the two profiles')
: __('Number of transactions between the two profiles'),
'arrow' => $this->networkView === 'flow',
// Two lines per relationship in the flow view: payments and
// receipts separately, because a single net arrow hides how much
// went each way.
// One entry per transaction type actually present in this
// graph: each arrow is a single type carrying that type's volume.
// Keep the colours in step with TYPE_EDGE_COLORS in
// stats-network.js.
'directions' => $this->networkView === 'flow'
? collect($network['edges'] ?? [])
->pluck('type_name', 'type_id')
->sortKeys()
->map(fn($name, $id) => [
'color' => [
1 => '#1e293b', 2 => '#4338ca', 3 => '#16a34a',
4 => '#eab308', 5 => '#ff3b00', 6 => '#0891b2',
][$id] ?? '#64748b',
'label' => $name,
])
->values()
->all()
: [],
];
}
/** Whether the network graph is limited to a bank's clients. */
public function isBankScoped(): bool
{
return $this->networkScope() !== null;
}
private function networkScope(): ?array
{
$profile = session('activeProfileType')::find(session('activeProfileId'));
if ($profile instanceof \App\Models\Admin) {
return null;
}
if (!$profile instanceof \App\Models\Bank) {
// authorizeStatsAccess() only admits Admin and Bank; an empty scope
// is the safe outcome here should that ever change.
return [];
}
return \DB::table('bank_clients')
->where('bank_id', $profile->id)
->get(['client_type', 'client_id'])
->map(fn($row) => $row->client_type . ':' . $row->client_id)
->all();
}
/**
* Close both heavy sections after a filter change.
*
* The user then clicks "Show statistics" or "Show network" again. That
* keeps a platform-wide aggregation from running on every filter tweak, and
* guarantees that what is on screen matches the current filters — a
* half-updated page is more misleading than an empty one.
*/
private function closeResultSections(): void
{
$this->showResults = false;
$this->showNetwork = false;
}
public function refreshStats(): void
{
$this->authorizeStatsAccess();
$this->fresh = true;
$this->showResults = true;
}
/** Explicitly run the statistics for the current period/filter selection. */
public function showStats(): void
{
$this->authorizeStatsAccess();
$this->showResults = true;
}
/**
* Sort the most-active-profiles table.
*
* Clicking the active column flips the direction; a new column starts
* descending, which is what "most active" means for every column here.
*/
public function sortTop(string $column): void
{
$this->authorizeStatsAccess();
if (!in_array($column, StatsFilter::TOP_SORTS, true)) {
return;
}
if ($this->topSort === $column) {
$this->topDirection = $this->topDirection === 'desc' ? 'asc' : 'desc';
} else {
$this->topSort = $column;
$this->topDirection = 'desc';
}
}
/** As sortTop(), for the activity-by-location table. */
public function sortLocation(string $column): void
{
$this->authorizeStatsAccess();
if (!in_array($column, StatsFilter::TOP_SORTS, true)) {
return;
}
if ($this->locationSort === $column) {
$this->locationDirection = $this->locationDirection === 'desc' ? 'asc' : 'desc';
} else {
$this->locationSort = $column;
$this->locationDirection = 'desc';
}
}
// -------------------------------------------------------------------- data
public function buildFilter(): StatsFilter
{
return new StatsFilter(
periodFrom: $this->fromDate ? \Carbon\Carbon::parse($this->fromDate) : null,
periodTill: $this->toDate ? \Carbon\Carbon::parse($this->toDate) : null,
countryIds: $this->filterByLocation ? $this->selectedCountryIds : [],
divisionIds: $this->filterByLocation ? $this->selectedDivisionIds : [],
cityIds: $this->filterByLocation ? $this->selectedCityIds : [],
districtIds: $this->filterByLocation ? $this->selectedDistrictIds : [],
registeredFrom: $this->filterByRegistration && $this->registeredFrom ? \Carbon\Carbon::parse($this->registeredFrom) : null,
registeredTill: $this->filterByRegistration && $this->registeredTill ? \Carbon\Carbon::parse($this->registeredTill) : null,
counterparties: $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [],
topSort: $this->topSort,
topDirection: $this->topDirection,
locationSort: $this->locationSort,
locationDirection: $this->locationDirection,
allTime: $this->allTime,
notLoggedInSince: $this->filterByLastLogin
? $this->inactivityCutoff($this->lastLoginPeriod, $this->lastLoginCustom)
: null,
noTransactionSince: $this->filterByLastTransaction
? $this->inactivityCutoff($this->lastTransactionPeriod, $this->lastTransactionCustom)
: null,
);
}
/**
* Persist the chart PNGs rendered client-side and hand the browser a PDF URL.
*
* Only the temp-file ids go into the session, not the images themselves.
*
* @param array<string,string> $chartImages data-URL PNGs keyed by chart name
*/
public function exportPdf(array $chartImages = []): void
{
$this->authorizeStatsAccess();
$filter = $this->buildFilter();
if ($filter->isEmpty()) {
return;
}
// Not an image: the counts sentence the classifier renders in the
// browser. Pulled out before the loop below, which would reject it as
// a malformed PNG.
$subtext = null;
if (isset($chartImages['networkSubtext'])) {
$subtext = is_string($chartImages['networkSubtext'])
? mb_substr(strip_tags($chartImages['networkSubtext']), 0, 300)
: null;
unset($chartImages['networkSubtext']);
}
$ids = [];
foreach ($chartImages as $name => $image) {
if (!is_string($image) || $image === '') {
continue;
}
$id = uniqid('stats_chart_', true);
$path = storage_path('app/temp/' . $id . '.png');
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $this->decodeChartImage($image));
$ids[preg_replace('/[^a-z0-9_]/i', '', (string) $name)] = $id;
}
session(['stats_chart_image_ids' => $ids]);
// The graph's settings travel alongside the image: a printed graph has
// no controls, so the PDF has to say which view produced it.
session(['stats_network_settings' => isset($ids['network'])
? $this->networkSettingsSummary()
: null]);
// The legend belongs with the image: without it the colours and sizes
// in a printed graph mean nothing. The graph is rebuilt here because
// render() holds it, not this method — the same filter and settings, so
// the legend matches the captured image.
if (isset($ids['network'])) {
$graph = (new StatsService($this->networkFilter($filter)))->network(
$this->networkMinDegree,
$this->networkScope(),
$this->networkEdge,
$this->networkTypes,
$this->networkMaxDegree,
$this->anonymizeProfiles
);
session([
'stats_network_legend' => $this->networkLegend($graph),
'stats_network_subtext' => $subtext,
]);
} else {
session(['stats_network_legend' => null, 'stats_network_subtext' => null]);
}
$this->dispatch('openStatsPdf', route('stats.pdf', $this->pdfParameters($filter, !empty($ids))));
}
/**
* Decode a data-URL image, rejecting anything that is not really a PNG/JPEG.
*/
private function decodeChartImage(string $chartImage): string
{
$base64Data = explode(',', $chartImage)[1] ?? $chartImage;
$imageData = base64_decode($base64Data, strict: true);
if ($imageData === false) {
abort(422, 'Invalid chart image data');
}
$isPng = str_starts_with($imageData, "\x89PNG");
$isJpeg = str_starts_with($imageData, "\xFF\xD8\xFF");
if (!$isPng && !$isJpeg) {
Log::warning('Stats: rejected chart image with invalid magic bytes', [
'profileType' => session('activeProfileType'),
'profileId' => session('activeProfileId'),
]);
abort(422, 'Chart image must be a PNG or JPEG');
}
return $imageData;
}
private function pdfParameters(StatsFilter $filter, bool $withCharts): array
{
return array_filter([
'fromDate' => $filter->periodFrom?->toDateString(),
'toDate' => $filter->periodTill?->toDateString(),
'countryIds' => implode(',', $filter->countryIds),
'divisionIds' => implode(',', $filter->divisionIds),
'cityIds' => implode(',', $filter->cityIds),
'districtIds' => implode(',', $filter->districtIds),
'registeredFrom' => $filter->registeredFrom?->toDateString(),
'registeredTill' => $filter->registeredTill?->toDateString(),
'counterparties' => collect($filter->counterparties)->map(fn($p) => $p['type'] . ':' . $p['id'])->implode(','),
'with_charts' => $withCharts ? 1 : null,
'decimal' => $this->decimalFormat ? 1 : 0,
// Sent as 0/1 rather than null-when-off: array_filter() below drops
// nulls, so "off" would arrive as a missing parameter and the
// controller would have to guess which way to default.
'anonymize' => $this->anonymizeProfiles ? 1 : 0,
'allTime' => $this->allTime ? 1 : 0,
], fn($value) => $value !== null && $value !== '');
}
/**
* Colours for the engagement chart series, taken from the tag-category
* colours in the database so the chart uses the platform's own palette.
*
* The series (posts, calls, reactions, messages) aren't categories, so
* there's no semantic mapping — the colours are sampled evenly across the
* ordered root categories purely for maximum visual separation.
*
* @return array<string,string> field => hex colour
*/
/**
* Transaction-type names for the economic chart, remembered so that every
* chartPalette() call allocates against the same layout. Without this the
* charts would each assume a different number of volume slots and could
* hand out the same colour twice.
*/
private array $volumeFields = ['volume'];
public function engagementSeriesColors(): array
{
return $this->chartPalette()['engagement'];
}
/**
* Colours for the economic chart, keyed by transaction-type name.
*
* Type names are data-driven (including an "Unknown" bucket for legacy
* ids), so they're passed in from the rendered timeline.
*
* @param array<string> $typeNames
* @return array<string,string>
*/
public function volumeSeriesColors(array $typeNames): array
{
if (!empty($typeNames)) {
$this->volumeFields = $typeNames;
}
return $this->chartPalette()['volume'];
}
/**
* Plain-language description of what is currently being filtered, so the
* numbers on the page are never ambiguous about their scope.
*
* @return array<int,array{label:string,value:string}>
*/
public function activeFilterSummary(): array
{
$summary = [];
$summary[] = [
'label' => __('Period'),
'value' => $this->fromDate && $this->toDate
? \Carbon\Carbon::parse($this->fromDate)->format('d-m-Y') . ' – ' . \Carbon\Carbon::parse($this->toDate)->format('d-m-Y')
: __('All time'),
];
if ($this->filterByLocation) {
$names = $this->selectedLocationNames();
$summary[] = [
'label' => __('Location'),
'value' => $names ?: __('No location selected yet'),
];
}
if ($this->filterByRegistration && ($this->registeredFrom || $this->registeredTill)) {
$from = $this->registeredFrom ? \Carbon\Carbon::parse($this->registeredFrom)->format('d-m-Y') : null;
$till = $this->registeredTill ? \Carbon\Carbon::parse($this->registeredTill)->format('d-m-Y') : null;
$summary[] = [
'label' => __('Registered'),
'value' => match (true) {
$from && $till => $from . ' – ' . $till,
(bool) $from => __('from :date', ['date' => $from]),
default => __('until :date', ['date' => $till]),
},
];
}
// Inactivity filters: show as a date, not a period name. "Since one
// year ago" is relative and means nothing in an exported PDF read later;
// a date stays unambiguous.
if ($this->filterByLastLogin) {
$cutoff = $this->inactivityCutoff($this->lastLoginPeriod, $this->lastLoginCustom);
$summary[] = [
'label' => __('Not logged in since'),
'value' => $cutoff ? $cutoff->format('d-m-Y') : __('All time'),
];
}
if ($this->filterByLastTransaction) {
$cutoff = $this->inactivityCutoff($this->lastTransactionPeriod, $this->lastTransactionCustom);
$summary[] = [
'label' => __('No transaction since'),
'value' => $cutoff ? $cutoff->format('d-m-Y') : __('All time'),
];
}
if ($this->filterByContacts && count($this->selectedContactProfiles) > 0) {
$summary[] = [
'label' => __('Transactions with'),
// Always named. The profiles you filtered on are the subject of
// the report, not data it discloses — without them the summary
// cannot say what it covers. Anonymisation still applies to the
// most-active table, where a name is a disclosure.
'value' => collect($this->selectedContactProfiles)->pluck('name')->implode(', '),
];
}
return $summary;
}
/**
* Translated names of the deepest selected location level.
*/
/**
* True when the selection is scoped to specific profiles' counterparties.
*
* The resolved set is then "everyone who traded with the selected
* profile(s)", plus those profiles themselves — so the page is describing
* trading partners rather than the platform's profiles at large, and the
* labels say so.
*/
public function isPartnerScoped(): bool
{
return $this->filterByContacts && count($this->selectedContactProfiles) > 0;
}
/**
* "Profiles", or "Partner profiles" when the selection is counterparty
* scoped. Centralised so a heading and its table never disagree.
*/
public function profilesLabel(bool $plural = true): string
{
if ($this->isPartnerScoped()) {
return $plural ? __('Partner profiles') : __('Partner profile');
}
return $plural ? __('Profiles') : __('Profile');
}
/**
* One-line restatement of the filters, for under a section heading.
*
* Balances are cumulative — the period never narrows them — so sections
* reporting a balance pass $withPeriod: false rather than repeating a date
* range that does not apply to their numbers.
*/
public function sectionScope(bool $withPeriod = true): string
{
return collect($this->activeFilterSummary())
->when(!$withPeriod, fn($items) => $items->reject(fn($item) => $item['label'] === __('Period')))
->map(fn($item) => $item['label'] . ': ' . $item['value'])
->implode(' · ');
}
private function selectedLocationNames(): string
{
$levels = [
[\App\Models\Locations\District::class, $this->selectedDistrictIds],
[\App\Models\Locations\City::class, $this->selectedCityIds],
[\App\Models\Locations\Division::class, $this->selectedDivisionIds],
[\App\Models\Locations\Country::class, $this->selectedCountryIds],
];
foreach ($levels as [$model, $ids]) {
if (empty($ids)) {
continue;
}
return $model::whereIn('id', $ids)->with('translations')->get()
->map(fn($row) => $row->translations->firstWhere('locale', app()->getLocale())?->name
?? $row->translations->first()?->name)
->filter()
->implode(', ');
}
return '';
}
/** One colour per charted profile type for the balance histogram. */
public function balanceSeriesColors(): array
{
return $this->chartPalette()['balance'];
}
/** One colour per profile type for the grouped registrations chart. */
public function registrationsSeriesColors(): array
{
return $this->chartPalette()['registrations'];
}
/** Single colour for the cumulative marked-as-inactive chart. */
public function inactivationsSeriesColors(): array
{
return $this->chartPalette()['inactivations'];
}
/**
* Allocate every chart colour on the page in one pass.
*
* Colours come from the tag-category colours in the database, so the charts
* use the platform's own palette. The series aren't categories, so there's
* no semantic mapping — slots are spread evenly across the ordered
* categories and handed out sequentially, which makes a repeated colour
* structurally impossible rather than something to hand-tune per chart.
*
* @return array{volume:array<string,string>, registrations:array<string,string>, engagement:array<string,string>}
*/
/**
* Fixed colour per single-metric chart.
*
* The DB-driven palette allocates colours by position, so adding or
* removing a series shifts every chart after it and two single-metric
* charts can end up looking alike. These eight each get an explicit,
* well-separated Tailwind-500 hue instead, so a chart always looks the same
* and never like its neighbour.
*
* Volume is absent on purpose: it is a stacked multi-series chart and keeps
* the category palette, so each transaction type stays its own colour.
*/
private const SINGLE_METRIC_COLORS = [
// Eight Tailwind-500 hues chosen for maximum separation on the colour
// wheel — at least 31 degrees apart, so no two charts read as the same
// colour. Side-by-side pairs take near-opposite hues.
'registrations.User' => '#3b82f6', // blue-500
'registrations.Organization' => '#f97316', // orange-500
'balance.User' => '#14b8a6', // teal-500
'balance.Organization' => '#d946ef', // fuchsia-500
'engagement.posts' => '#ec4899', // pink-500
'engagement.calls' => '#22c55e', // green-500
'engagement.reactions' => '#84cc16', // lime-500
'engagement.messages' => '#8b5cf6', // violet-500
// Marked as inactive: a warning-toned amber, in the gap between
// orange-500 and lime-500 so it stays distinct from both.
'inactivations.count' => '#f59e0b', // amber-500
// The balance line shares that chart, so it takes a hue far from
// amber: slate reads as a secondary reference line, not a rival metric.
'inactivations.balance' => '#64748b', // slate-500
// Distinct from both the amber count and the slate balance.
'inactivations.incomplete' => '#0ea5e9', // sky-500
];
private function chartPalette(): array
{
$volumeFields = $this->volumeFields;
// Banks are charted in neither the registrations nor the balance
// distribution, so they get no colour slot.
$registrationFields = ['User', 'Organization'];
$engagementFields = ['posts', 'calls', 'reactions', 'messages'];
// Banks are deliberately absent: their balance is the mirror of every
// other profile's, so the distribution chart omits them.
$balanceFields = ['User', 'Organization'];
$needed = count($volumeFields) + count($registrationFields)
+ count($engagementFields) + count($balanceFields);
$colors = \Illuminate\Support\Facades\Cache::remember('stats:series_colors:v1', 3600, function () {
return \App\Models\Category::whereNull('parent_id')
->where('type', \App\Models\Tag::class)
->whereNotNull('color')
->orderBy('id')
->pluck('color')
->all();
});
if (empty($colors)) {
// Tailwind-400 equivalents, matching tailwindColorToHex()'s shade.
$hexes = ['#f87171', '#fb923c', '#facc15', '#4ade80', '#2dd4bf', '#60a5fa', '#a78bfa', '#f472b6'];
} else {
// Resolve to hex first and drop duplicates: distinct category names
// can map to the same Tailwind hex (sky and blue both give #60a5fa),
// which would otherwise put identical colours on two charts.
$resolved = [];
foreach ($colors as $name) {
$hex = tailwindColorToHex($name . '-400');
if (!in_array($hex, $resolved, true)) {
$resolved[] = $hex;
}
}
// Step roughly a third of the way around the wheel each time for
// strong hue separation, skipping indices already taken so every
// colour is used before any repeats.
$count = count($resolved);
$step = max(1, (int) round($count / 3));
$hexes = [];
$used = [];
$index = 0;
for ($i = 0; $i < $needed; $i++) {
$guard = 0;
while (isset($used[$index]) && $guard++ < $count) {
$index = ($index + 1) % $count;
}
$used[$index] = true;
$hexes[] = $resolved[$index];
$index = ($index + $step) % $count;
}
}
$slot = 0;
// by-reference: a value-capture closure would hand out the same slot.
$take = function () use ($hexes, &$slot) {
return $hexes[$slot++ % count($hexes)];
};
// Single-metric charts take their fixed hue; only the stacked volume
// chart draws from the allocated category palette.
$fixed = fn(string $group, array $fields) => collect($fields)
->mapWithKeys(fn($f) => [
$f => self::SINGLE_METRIC_COLORS["$group.$f"] ?? $take(),
])
->all();
return [
'volume' => collect($volumeFields)->mapWithKeys(fn($f) => [$f => $take()])->all(),
'registrations' => $fixed('registrations', $registrationFields),
'engagement' => $fixed('engagement', $engagementFields),
'balance' => $fixed('balance', $balanceFields),
'inactivations' => $fixed('inactivations', ['count', 'balance', 'incomplete']),
];
}
public function formatAmount($minutes): string
{
if ($this->decimalFormat) {
$isNegative = $minutes < 0;
$decimal = number_format(abs($minutes) / 60, 2, ',', '.');
return ($isNegative ? '-' : '') . $decimal . ' h.';
}
return tbFormat($minutes);
}
public function render()
{
$this->authorizeStatsAccess();
$filter = $this->buildFilter();
$stats = null;
if ($this->showResults && !$filter->isEmpty()) {
$stats = (new StatsService($filter))->all($this->fresh);
$this->fresh = false;
// Fix the economic chart's series names up front so every colour
// lookup allocates against the same layout, whatever order the
// view happens to call them in.
$first = $stats['economic']['timeline'][0] ?? [];
$names = array_values(array_diff(array_keys($first), ['label', 'ym', 'count', 'volume']));
$this->volumeFields = $names ?: ['volume'];
}
// Deliberately outside all(), and independent of $stats: the graph
// stands on its own. "Show network" need not wait for "Show
// statistics", and its own period choice makes it usable separately.
$network = null;
if ($this->showNetwork) {
// Without a page filter there is no period yet; the graph falls
// back on its own choice, or on the last year.
$graphFilter = $this->networkFilter($filter);
// Only fall back when nothing was chosen at all. "All time" IS a
// choice: overriding its null dates with the last year makes the
// graph contradict the filter the user selected.
if ($graphFilter->periodFrom === null && $graphFilter->periodTill === null
&& $this->networkPeriod === 'inherit'
&& !$this->allTime) {
$graphFilter = new StatsFilter(
periodFrom: \Carbon\Carbon::now()->subYear(),
periodTill: \Carbon\Carbon::now(),
countryIds: $filter->countryIds,
divisionIds: $filter->divisionIds,
cityIds: $filter->cityIds,
districtIds: $filter->districtIds,
registeredFrom: $filter->registeredFrom,
registeredTill: $filter->registeredTill,
counterparties: $filter->counterparties,
topSort: $filter->topSort,
topDirection: $filter->topDirection,
locationSort: $filter->locationSort,
locationDirection: $filter->locationDirection,
notLoggedInSince: $filter->notLoggedInSince,
noTransactionSince: $filter->noTransactionSince,
// Carried through: rebuilding the filter here without them
// silently dropped both toggles whenever the graph fell
// back to its default period.
includeRemoved: $this->networkIncludeRemoved,
markInactive: $this->networkMarkInactive,
);
}
$scope = $this->networkScope();
$network = (new StatsService($graphFilter))
->network(
$this->networkMinDegree,
$scope,
$this->networkEdge,
$this->networkTypes,
$this->networkMaxDegree,
$this->anonymizeProfiles
);
}
return view('livewire.admin.stats', [
'stats' => $stats,
'hasFilter' => $stats !== null,
'network' => $network,
]);
}
}