Skip to content

Commit

Permalink
feat: migrate Developer Feed page to React (#2981)
Browse files Browse the repository at this point in the history
  • Loading branch information
wescopeland authored Jan 2, 2025
1 parent e5b24bc commit 3dfc81a
Show file tree
Hide file tree
Showing 59 changed files with 1,622 additions and 1,526 deletions.
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();
}

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

0 comments on commit 3dfc81a

Please sign in to comment.