Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: migrate Developer Feed page to React #2981

Merged
204 changes: 204 additions & 0 deletions app/Community/Actions/BuildDeveloperFeedDataAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

declare(strict_types=1);

namespace App\Community\Actions;

use App\Community\Data\DeveloperFeedPagePropsData;
use App\Community\Data\RecentLeaderboardEntryData;
use App\Community\Data\RecentPlayerBadgeData;
use App\Community\Data\RecentUnlockData;
use App\Community\Enums\AwardType;
use App\Data\UserData;
use App\Models\LeaderboardEntry;
use App\Models\PlayerAchievement;
use App\Models\PlayerBadge;
use App\Models\User;
use App\Platform\Data\AchievementData;
use App\Platform\Data\GameData;
use App\Platform\Data\LeaderboardData;
use App\Platform\Data\LeaderboardEntryData;
use App\Platform\Enums\UnlockMode;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class BuildDeveloperFeedDataAction
{
public function execute(User $targetUser): DeveloperFeedPagePropsData
{
// Use DB::table() to avoid loading potentially thousands of Eloquent models into memory.
$achievementInfo = DB::table('Achievements')
->select(['ID', 'GameID'])
->where('user_id', $targetUser->id)
->get();

$allUserAchievementIds = $achievementInfo->pluck('ID');
$allUserGameIds = $achievementInfo->pluck('GameID')->unique();

$activePlayers = (new BuildActivePlayersAction())->execute(gameIds: $allUserGameIds->toArray());

$recentUnlocks = $this->getRecentUnlocks(
$allUserAchievementIds,
shouldUseDateRange: $targetUser->ContribCount <= 20_000,
);

$recentPlayerBadges = $this->getRecentPlayerBadges($allUserGameIds->toArray());

$recentLeaderboardEntries = $this->getRecentLeaderboardEntries($targetUser);

$props = new DeveloperFeedPagePropsData(
activePlayers: $activePlayers,
developer: UserData::from($targetUser),
unlocksContributed: $targetUser->ContribCount ?? 0,
pointsContributed: $targetUser->ContribYield ?? 0,
awardsContributed: $this->countAwardsForGames($allUserGameIds->toArray()),
leaderboardEntriesContributed: $this->countLeaderboardEntries($targetUser),
recentUnlocks: $recentUnlocks,
recentPlayerBadges: $recentPlayerBadges,
recentLeaderboardEntries: $recentLeaderboardEntries,
);

return $props;
}

private function countAwardsForGames(array $gameIds): int
{
// This query counts unique mastery/beaten awards per user/game, taking only
// the highest tier (AwardDataExtra) when multiple per user exist. Using a
// window function instead of a self-join improves performance from 130-180ms
// down to ~40ms.

return DB::table(DB::raw('(
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY AwardData, AwardType, user_id
ORDER BY AwardDataExtra DESC
) as rn
FROM SiteAwards
WHERE AwardData IN (' . implode(',', $gameIds) . ')
AND AwardType IN (' . AwardType::Mastery . ', ' . AwardType::GameBeaten . ')
) ranked'))
->where('rn', 1)
->count();
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
}

private function countLeaderboardEntries(User $user): int
{
// We're using a JOIN instead of a subquery with IN here because MySQL can better
// optimize the execution plan with this specific query. With a subquery, MySQL
// will try to materialize the results first, while with a JOIN it can choose the
// most efficient way to combine the tables. This reduces query time by ~10x.

return DB::table('leaderboard_entries')
->join('LeaderboardDef', 'LeaderboardDef.ID', '=', 'leaderboard_entries.leaderboard_id')
->where('LeaderboardDef.author_id', $user->id)
->count();
}

/**
* @param Collection<int, int> $achievementIds
* @return RecentUnlockData[]
*/
private function getRecentUnlocks(Collection $achievementIds, bool $shouldUseDateRange = false): array
{
$query = PlayerAchievement::with(['achievement', 'achievement.game', 'achievement.game.system', 'user'])
->whereIn('achievement_id', $achievementIds)
->orderByDesc('unlocked_at');

if ($shouldUseDateRange) {
$thirtyDaysAgo = Carbon::now()->subDays(30);
$query->whereDate('unlocked_at', '>=', $thirtyDaysAgo);
}

return $query
->take(200)
->get()
->reject(fn ($unlock) => $unlock->user->Untracked)
->map(fn ($unlock) => new RecentUnlockData(
achievement: AchievementData::fromAchievement($unlock->achievement)->include('badgeUnlockedUrl', 'points'),
game: GameData::fromGame($unlock->achievement->game)->include('badgeUrl', 'system.iconUrl', 'system.nameShort'),
user: UserData::fromUser($unlock->user),
unlockedAt: $unlock->unlocked_at,
isHardcore: $unlock->unlocked_hardcore_at !== null,
))
->values()
->all();
}

/**
* @return RecentPlayerBadgeData[]
*/
private function getRecentPlayerBadges(array $gameIds): array
{
$thirtyDaysAgo = Carbon::now()->subDays(30);

return PlayerBadge::from('SiteAwards as pb')
->with(['user', 'gameIfApplicable', 'gameIfApplicable.system'])
->whereIn('pb.AwardData', $gameIds)
->whereIn('pb.AwardType', [AwardType::Mastery, AwardType::GameBeaten])
->whereDate('pb.AwardDate', '>=', $thirtyDaysAgo)
->joinSub(
PlayerBadge::selectRaw('MAX(AwardDataExtra) as MaxExtra, AwardData, AwardType, user_id')
->groupBy('AwardData', 'AwardType', 'user_id'),
'priority_awards',
function ($join) {
$join->on('pb.AwardData', '=', 'priority_awards.AwardData')
->on('pb.AwardType', '=', 'priority_awards.AwardType')
->on('pb.user_id', '=', 'priority_awards.user_id')
->on('pb.AwardDataExtra', '=', 'priority_awards.MaxExtra');
}
)
->orderByDesc('pb.AwardDate')
->take(50)
->get()
->reject(fn ($award) => $award->user->Untracked)
->map(fn ($award) => new RecentPlayerBadgeData(
game: GameData::fromGame($award->gameIfApplicable)->include('badgeUrl', 'system.iconUrl', 'system.nameShort'),
awardType: $award->AwardDataExtra === UnlockMode::Hardcore
? ($award->AwardType === AwardType::Mastery ? 'mastered' : 'beaten-hardcore')
: ($award->AwardType === AwardType::Mastery ? 'completed' : 'beaten-softcore'),
user: UserData::fromUser($award->user),
earnedAt: $award->AwardDate,
))
->values()
->all();
}

/**
* @return RecentLeaderboardEntryData[]
*/
private function getRecentLeaderboardEntries(User $targetUser): array
{
$thirtyDaysAgo = Carbon::now()->subDays(30);

// Using FORCE INDEX in MySQL/MariaDB dramatically improves performance (from ~550ms to ~20ms).
// We conditionally apply the hint only when using MySQL/MariaDB. It is not supported by SQLite.
$query = LeaderboardEntry::query();

if (DB::connection()->getDriverName() === 'mariadb') {
$query->from(DB::raw('leaderboard_entries FORCE INDEX (idx_recent_entries)'));
}

return $query
->with(['leaderboard', 'leaderboard.game', 'leaderboard.game.system', 'user'])
->join('LeaderboardDef', 'LeaderboardDef.ID', '=', 'leaderboard_entries.leaderboard_id')
->where('LeaderboardDef.author_id', $targetUser->id)
->whereNull('leaderboard_entries.deleted_at')
->where('leaderboard_entries.updated_at', '>=', $thirtyDaysAgo)
->select('leaderboard_entries.*')
->orderBy('leaderboard_entries.updated_at', 'desc')
->take(200)
->get()
->reject(fn ($entry) => $entry->user->Untracked)
->map(fn ($entry) => new RecentLeaderboardEntryData(
leaderboard: LeaderboardData::fromLeaderboard($entry->leaderboard),
leaderboardEntry: LeaderboardEntryData::fromLeaderboardEntry($entry)->include('formattedScore'),
game: GameData::fromGame($entry->leaderboard->game)->include('badgeUrl', 'system.iconUrl', 'system.nameShort'),
user: UserData::fromUser($entry->user),
submittedAt: $entry->updated_at,
))
->values()
->all();
}
}
2 changes: 0 additions & 2 deletions app/Community/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use App\Community\Commands\SyncForums;
use App\Community\Commands\SyncTickets;
use App\Community\Commands\SyncUserRelations;
use App\Community\Components\ActivePlayers;
use App\Community\Components\DeveloperGameStatsTable;
use App\Community\Components\ForumRecentActivity;
use App\Community\Components\MessageIcon;
Expand Down Expand Up @@ -96,7 +95,6 @@ public function boot(): void
TriggerTicketComment::disableSearchSyncing();
UserComment::disableSearchSyncing();

Blade::component('active-players', ActivePlayers::class);
Blade::component('developer-game-stats-table', DeveloperGameStatsTable::class);
Blade::component('forum-recent-activity', ForumRecentActivity::class);
Blade::component('user-card', UserCard::class);
Expand Down
48 changes: 0 additions & 48 deletions app/Community/Components/ActivePlayers.php

This file was deleted.

4 changes: 2 additions & 2 deletions app/Community/Components/UserProfileMeta.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ private function buildDeveloperStats(User $user, array $userMassData): array
$achievementsUnlockedByPlayersStat = [
'label' => 'Achievements unlocked by players',
'value' => localized_number($userMassData['ContribCount']),
'href' => $userMassData['ContribCount'] > 0 ? route('developer.feed', ['user' => $user]) : null,
'href' => $userMassData['ContribCount'] > 0 ? route('user.achievement-author.feed', ['user' => $user]) : null,
'isMuted' => !$userMassData['ContribCount'],
];

// Points awarded to players
$pointsAwardedToPlayersStat = [
'label' => 'Points awarded to players',
'value' => localized_number($userMassData['ContribYield']),
'href' => $userMassData['ContribYield'] > 0 ? route('developer.feed', ['user' => $user]) : null,
'href' => $userMassData['ContribYield'] > 0 ? route('user.achievement-author.feed', ['user' => $user]) : null,
'isMuted' => !$userMassData['ContribYield'],
];

Expand Down
52 changes: 52 additions & 0 deletions app/Community/Controllers/AchievementAuthorController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace App\Community\Controllers;

use App\Community\Actions\BuildDeveloperFeedDataAction;
use App\Http\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;

class AchievementAuthorController extends Controller
{
// TODO developerstats.php?
public function index(): void
{
}

// TODO individualdevstats.php?
public function show(): void
{
}

public function create(): void
{
}

public function edit(): void
{
}

public function update(): void
{
}

public function destroy(): void
{
}

public function feed(Request $request, User $user): InertiaResponse
{
abort_if($user->ContribCount === 0, 404);

$this->authorize('viewDeveloperFeed', $user);

$props = (new BuildDeveloperFeedDataAction())->execute($user);

return Inertia::render('user/[user]/developer/feed', $props);
}
}
30 changes: 30 additions & 0 deletions app/Community/Data/DeveloperFeedPagePropsData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Community\Data;

use App\Data\PaginatedData;
use App\Data\UserData;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript('DeveloperFeedPageProps<TItems = App.Community.Data.ActivePlayer>')]
class DeveloperFeedPagePropsData extends Data
{
public function __construct(
public UserData $developer,
public int $unlocksContributed,
public int $pointsContributed,
public int $awardsContributed,
public int $leaderboardEntriesContributed,
public PaginatedData $activePlayers,
/** @var RecentUnlockData[] */
public array $recentUnlocks,
/** @var RecentPlayerBadgeData[] */
public array $recentPlayerBadges,
/** @var RecentLeaderboardEntryData[] */
public array $recentLeaderboardEntries,
) {
}
}
26 changes: 26 additions & 0 deletions app/Community/Data/RecentLeaderboardEntryData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Community\Data;

use App\Data\UserData;
use App\Platform\Data\GameData;
use App\Platform\Data\LeaderboardData;
use App\Platform\Data\LeaderboardEntryData;
use Carbon\Carbon;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript('RecentLeaderboardEntry')]
class RecentLeaderboardEntryData extends Data
{
public function __construct(
public LeaderboardData $leaderboard,
public LeaderboardEntryData $leaderboardEntry,
public GameData $game,
public UserData $user,
public Carbon $submittedAt,
) {
}
}
Loading