Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

@@ -0,0 +1,49 @@
<?php
namespace App\Services\Stats;
/**
* Resolves a set of location selections down to the single most specific level.
*
* Combining levels with OR would let a broader level (e.g. country) swallow a
* narrower one (e.g. division), making the narrower filter ineffective — so only
* the deepest level that has selections is applied.
*/
class LocationFilterResolver
{
/**
* @return array{column: string, ids: array<int>}|null null when nothing is selected
*/
public static function resolve(array $countryIds, array $divisionIds, array $cityIds, array $districtIds): ?array
{
foreach ([
'district_id' => $districtIds,
'city_id' => $cityIds,
'division_id' => $divisionIds,
'country_id' => $countryIds,
] as $column => $ids) {
$ids = array_values(array_filter(array_map('intval', $ids)));
if (!empty($ids)) {
return ['column' => $column, 'ids' => $ids];
}
}
return null;
}
/**
* Constrain a profile query to profiles whose location matches the deepest selected level.
*/
public static function apply($query, array $countryIds, array $divisionIds, array $cityIds, array $districtIds)
{
$resolved = self::resolve($countryIds, $divisionIds, $cityIds, $districtIds);
if ($resolved === null) {
return $query;
}
return $query->whereHas('locations', function ($q) use ($resolved) {
$q->whereIn($resolved['column'], $resolved['ids']);
});
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Stats;
/**
* Which keys occur inside a date window that only ever moves forward.
*
* Built for the network time-lapse: its layers are windows over a day-ordered
* list of [day, key] events - "traded in the month ending on this step",
* "traded before this date" - and each step moves them on by a week or a
* month. Two pointers walk the events once in total, so all steps together
* cost one pass, and each step reports only the keys that entered or left.
*
* Days are 'Y-m-d' strings, which compare correctly as strings.
*/
class SlidingWindow
{
/** @var array<string,int> Occurrences of each key inside the window. */
private array $count = [];
private int $enter = 0;
private int $leave = 0;
private int $size;
/** @param array<array{0:string, 1:string}> $events [day, key], ordered by day */
public function __construct(private array $events)
{
$this->size = count($events);
}
/**
* Move the window to [$start, $end] (inclusive; null start = no lower
* bound) and return what entered and left since the previous call.
*
* Both bounds may only move forward.
*
* @return array{added: array<string>, removed: array<string>}
*/
public function slideTo(?string $start, string $end): array
{
$before = [];
while ($this->enter < $this->size && $this->events[$this->enter][0] <= $end) {
$key = $this->events[$this->enter++][1];
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
$this->count[$key] = ($this->count[$key] ?? 0) + 1;
}
if ($start !== null) {
while ($this->leave < $this->enter && $this->events[$this->leave][0] < $start) {
$key = $this->events[$this->leave++][1];
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
if (--$this->count[$key] === 0) {
unset($this->count[$key]);
}
}
}
$added = [];
$removed = [];
foreach ($before as $key => $was) {
$now = isset($this->count[$key]);
if ($now && !$was) {
$added[] = $key;
} elseif (!$now && $was) {
$removed[] = $key;
}
}
return ['added' => $added, 'removed' => $removed];
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Stats;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Carbon\Carbon;
/**
* Immutable description of a Stats query: which profiles, over which period.
*
* Filters combine with AND. An empty filter means "nothing selected yet" — the
* Stats view renders its empty state rather than querying the whole platform.
*/
final class StatsFilter
{
/**
* Profile types that take part in platform statistics.
*
* Admin is deliberately excluded: admins hold no accounts, and counting them
* as community members would skew registration and engagement figures.
*/
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class];
public function __construct(
public readonly ?Carbon $periodFrom = null,
public readonly ?Carbon $periodTill = null,
public readonly array $countryIds = [],
public readonly array $divisionIds = [],
public readonly array $cityIds = [],
public readonly array $districtIds = [],
public readonly ?Carbon $registeredFrom = null,
public readonly ?Carbon $registeredTill = null,
/** @var array<array{id:int,type:class-string}> */
public readonly array $counterparties = [],
/**
* Sort for the "most active profiles" table. It belongs here rather
* than in the view because the table is a top-20 cut: sorting after the
* fact would only reorder those twenty, not find the real top payers.
*/
public readonly string $topSort = 'count',
public readonly string $topDirection = 'desc',
/** Same, for the "activity by location" table, which sorts separately. */
public readonly string $locationSort = 'count',
public readonly string $locationDirection = 'desc',
/**
* "All time" chosen deliberately. Without it, null dates are
* indistinguishable from "nothing selected yet" and isEmpty() would
* send the page to its empty state instead of reporting on everything.
*/
public readonly bool $allTime = false,
/**
* Profiles that have not logged in since this date.
*
* An upper bound, not a range: "not logged in since X" means
* last_login_at < X, or never logged in at all. Hence a single date.
*/
public readonly ?Carbon $notLoggedInSince = null,
/**
* Profiles without a transaction since this date. Same shape as the
* above, and deliberately separate from periodFrom: that bounds which
* transactions count, this one bounds who is in the selection.
*/
public readonly ?Carbon $noTransactionSince = null,
/**
* Include profiles their owner has closed (deleted_at set).
*
* Off by default: the graph describes the live network. On, it also
* shows where hours went when a profile was closed - all 134 currency
* removals on dev are balance transfers to a bank made during closure,
* so they are invisible without this.
*/
public readonly bool $includeRemoved = false,
/**
* Mark profiles that carry inactive_at with a blue outline.
*
* NOT an inclusion filter, despite the toggle sitting next to one:
* notRemoved() only tests deleted_at, so inactive profiles were never
* excluded in the first place. This just asks for them to be
* identified.
*/
public readonly bool $markInactive = false,
) {}
/**
* A copy of this filter with a different period.
*
* Every other criterion is carried over, so a time-lapse frame keeps the
* location, registration and counterparty filters the user set - only the
* dates move. Readonly promoted properties cannot be reassigned, hence a
* new instance rather than a mutation.
*/
public function withPeriod(?Carbon $from, ?Carbon $till): self
{
return new self(
periodFrom: $from,
periodTill: $till,
countryIds: $this->countryIds,
divisionIds: $this->divisionIds,
cityIds: $this->cityIds,
districtIds: $this->districtIds,
registeredFrom: $this->registeredFrom,
registeredTill: $this->registeredTill,
counterparties: $this->counterparties,
topSort: $this->topSort,
topDirection: $this->topDirection,
locationSort: $this->locationSort,
locationDirection: $this->locationDirection,
// Deliberately NOT carried: allTime means "no period at all", which
// contradicts the explicit dates being set here.
notLoggedInSince: $this->notLoggedInSince,
noTransactionSince: $this->noTransactionSince,
includeRemoved: $this->includeRemoved,
markInactive: $this->markInactive,
);
}
/** Columns the top-profiles table may be ordered by. */
public const TOP_SORTS = [
'paid_count', 'received_count', 'paid_volume', 'received_volume', 'count', 'partners',
];
/**
* The sort column as an SQL alias, guarded against anything off the
* allow-list.
*
* "count" is the UI's name for the combined figure; the query aliases it
* "cnt", because COUNT is reserved. Mapping here keeps that detail out of
* the view.
*/
public function topSortColumn(): string
{
$column = in_array($this->topSort, self::TOP_SORTS, true) ? $this->topSort : 'count';
return $column === 'count' ? 'cnt' : $column;
}
public function topSortDirection(): string
{
return strtolower($this->topDirection) === 'asc' ? 'asc' : 'desc';
}
/** As topSortColumn(), for the location table. */
public function locationSortColumn(): string
{
$column = in_array($this->locationSort, self::TOP_SORTS, true) ? $this->locationSort : 'count';
return $column === 'count' ? 'cnt' : $column;
}
public function locationSortDirection(): string
{
return strtolower($this->locationDirection) === 'asc' ? 'asc' : 'desc';
}
public static function fromArray(array $data): self
{
$date = fn(?string $value) => $value ? Carbon::parse($value) : null;
return new self(
periodFrom: $date($data['periodFrom'] ?? null),
periodTill: $date($data['periodTill'] ?? null),
countryIds: $data['countryIds'] ?? [],
divisionIds: $data['divisionIds'] ?? [],
cityIds: $data['cityIds'] ?? [],
districtIds: $data['districtIds'] ?? [],
registeredFrom: $date($data['registeredFrom'] ?? null),
registeredTill: $date($data['registeredTill'] ?? null),
counterparties: $data['counterparties'] ?? [],
topSort: $data['topSort'] ?? 'count',
topDirection: $data['topDirection'] ?? 'desc',
locationSort: $data['locationSort'] ?? 'count',
locationDirection: $data['locationDirection'] ?? 'desc',
allTime: (bool) ($data['allTime'] ?? false),
);
}
public function hasLocationFilter(): bool
{
return LocationFilterResolver::resolve(
$this->countryIds,
$this->divisionIds,
$this->cityIds,
$this->districtIds
) !== null;
}
public function hasRegistrationFilter(): bool
{
return $this->registeredFrom !== null || $this->registeredTill !== null;
}
public function hasPeriod(): bool
{
return $this->periodFrom !== null || $this->periodTill !== null;
}
/**
* True when nothing has been selected — the caller should render an empty
* state instead of building a service and querying the entire platform.
*/
public function isEmpty(): bool
{
return !$this->allTime
&& !$this->hasPeriod()
&& !$this->hasLocationFilter()
&& !$this->hasRegistrationFilter()
&& $this->notLoggedInSince === null
&& $this->noTransactionSince === null
&& empty($this->counterparties);
}
/**
* Stable cache key for this exact filter combination.
*/
public function hash(): string
{
return md5(serialize([
$this->periodFrom?->toDateString(),
$this->periodTill?->toDateString(),
$this->countryIds,
$this->divisionIds,
$this->cityIds,
$this->districtIds,
$this->registeredFrom?->toDateString(),
$this->registeredTill?->toDateString(),
$this->counterparties,
$this->topSortColumn(),
$this->topSortDirection(),
$this->locationSortColumn(),
$this->locationSortDirection(),
$this->allTime,
$this->notLoggedInSince?->toDateString(),
$this->noTransactionSince?->toDateString(),
$this->includeRemoved,
$this->markInactive,
]));
}
}