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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/Community/Actions/BuildDeveloperFeedDataAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
namespace App\Community\Actions;

use App\Community\Data\DeveloperFeedPagePropsData;
use App\Community\Data\FeedRecentUnlockData;
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;
Expand Down Expand Up @@ -118,7 +118,7 @@ private function countLeaderboardEntries(User $user): int

/**
* @param Collection<int, int> $achievementIds
* @return RecentUnlockData[]
* @return FeedRecentUnlockData[]
*/
private function getRecentUnlocks(Collection $achievementIds, bool $shouldUseDateRange = false): array
{
Expand All @@ -135,7 +135,7 @@ private function getRecentUnlocks(Collection $achievementIds, bool $shouldUseDat
->take(200)
->get()
->reject(fn ($unlock) => $unlock->user->unranked_at !== null)
->map(fn ($unlock) => new RecentUnlockData(
->map(fn ($unlock) => new FeedRecentUnlockData(
achievement: AchievementData::fromAchievement($unlock->achievement)->include('points'),
game: GameData::fromGame($unlock->achievement->game)->include('badgeUrl', 'system.iconUrl', 'system.nameShort'),
user: UserData::fromUser($unlock->user),
Expand Down
2 changes: 1 addition & 1 deletion app/Community/Data/DeveloperFeedPagePropsData.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public function __construct(
public int $awardsContributed,
public int $leaderboardEntriesContributed,
public PaginatedData $activePlayers,
/** @var RecentUnlockData[] */
/** @var FeedRecentUnlockData[] */
public array $recentUnlocks,
/** @var RecentPlayerBadgeData[] */
public array $recentPlayerBadges,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript('RecentUnlock')]
class RecentUnlockData extends Data
#[TypeScript('FeedRecentUnlock')]
class FeedRecentUnlockData extends Data
{
public function __construct(
public AchievementData $achievement,
Expand Down
12 changes: 12 additions & 0 deletions app/Models/PlayerAchievement.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,16 @@ public function scopeForGame(Builder $query, Game $game): Builder
$query->where('game_id', $game->id);
});
}

/**
* @param Builder<PlayerAchievement> $query
* @return Builder<PlayerAchievement>
*/
public function scopeRanked(Builder $query): Builder
{
return $query
->addSelect('player_achievements.*')
->leftJoin('unranked_users', 'player_achievements.user_id', '=', 'unranked_users.user_id')
->whereNull('unranked_users.id');
}
}
17 changes: 17 additions & 0 deletions app/Platform/Controllers/AchievementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Community\Data\CommentData;
use App\Community\Enums\SubscriptionSubjectType;
use App\Community\Services\SubscriptionService;
use App\Data\UserData;
use App\Data\UserPermissionsData;
use App\Http\Controller;
use App\Models\Achievement;
Expand All @@ -16,6 +17,7 @@
use App\Models\Role;
use App\Models\User;
use App\Platform\Data\AchievementData;
use App\Platform\Data\AchievementRecentUnlockData;
use App\Platform\Data\AchievementShowPagePropsData;
use App\Platform\Data\GameAchievementSetData;
use App\Platform\Data\GameData;
Expand All @@ -26,6 +28,7 @@
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Spatie\LaravelData\Lazy;

class AchievementController extends Controller
{
Expand Down Expand Up @@ -111,6 +114,20 @@ public function show(Request $request, Achievement $achievement): InertiaRespons
: null,
proximityAchievements: $proximityAchievements,
promotedAchievementCount: $promotedAchievementCount,
recentUnlocks: Lazy::inertiaDeferred(function () use ($achievement) {
return PlayerAchievement::with('user')
->whereHas('user')
->where('achievement_id', $achievement->id)
->ranked()
->orderByDesc('unlocked_effective_at')
->limit(50)
->get()
->map(fn ($pa) => new AchievementRecentUnlockData(
user: UserData::fromUser($pa->user)->include('displayName', 'avatarUrl'),
unlockedAt: $pa->unlocked_effective_at,
isHardcore: $pa->unlocked_hardcore_at !== null,
));
}),
initialTab: $initialTab,
);

Expand Down
21 changes: 21 additions & 0 deletions app/Platform/Data/AchievementRecentUnlockData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Platform\Data;

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

#[TypeScript('AchievementRecentUnlock')]
class AchievementRecentUnlockData extends Data
{
public function __construct(
public UserData $user,
public Carbon $unlockedAt,
public bool $isHardcore,
) {
}
}
5 changes: 5 additions & 0 deletions app/Platform/Data/AchievementShowPagePropsData.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
use App\Data\UserPermissionsData;
use App\Platform\Enums\AchievementPageTab;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Attributes\AutoInertiaDeferred;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Lazy;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript('AchievementShowPageProps')]
Expand All @@ -17,6 +19,7 @@ class AchievementShowPagePropsData extends Data
/**
* @param Collection<int, CommentData> $recentVisibleComments
* @param AchievementData[]|null $proximityAchievements
* @param Collection<int, AchievementRecentUnlockData> $recentUnlocks
*/
public function __construct(
public AchievementData $achievement,
Expand All @@ -28,6 +31,8 @@ public function __construct(
public ?GameAchievementSetData $gameAchievementSet = null,
public ?array $proximityAchievements = null,
public int $promotedAchievementCount = 0,
#[AutoInertiaDeferred]
public Lazy|Collection $recentUnlocks = new Collection(),
public AchievementPageTab $initialTab = AchievementPageTab::Comments,
) {
}
Expand Down
1 change: 1 addition & 0 deletions lang/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,7 @@
"Award tier selection": "Award tier selection",
"Developer Events": "Developer Events",
"Community Events": "Community Events",
"No unlocks found for this achievement.": "No unlocks found for this achievement.",
"Achievement Data": "Achievement Data",
"Achievement titles, descriptions, and related metadata may be referenced or reused by third parties (eg: for tracking sites or community tools).": "Achievement titles, descriptions, and related metadata may be referenced or reused by third parties (eg: for tracking sites or community tools).",
"Achievement trigger logic (the code and conditions that define how achievements are evaluated) is proprietary to the RetroAchievements community and may not be reused, redistributed, or reproduced without explicit permission. The GPL-3 license covering the RetroAchievements web application source code does not extend to achievement data.": "Achievement trigger logic (the code and conditions that define how achievements are evaluated) is proprietary to the RetroAchievements community and may not be reused, redistributed, or reproduced without explicit permission. The GPL-3 license covering the RetroAchievements web application source code does not extend to achievement data."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('Component: AchievementShowRoot', () => {
await userEvent.click(screen.getByRole('tab', { name: /unlocks/i }));

// ASSERT
expect(screen.getByText(/AchievementRecentUnlocks/i)).toBeVisible();
expect(screen.getByRole('table')).toBeVisible();
});

it('given the user hovers over an inactive tab, applies the hover text style', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AchievementCommentList } from '../AchievementCommentList';
import { AchievementGamePanel } from '../AchievementGamePanel';
import { AchievementHero } from '../AchievementHero';
import { AchievementInlineActions } from '../AchievementInlineActions';
import { AchievementRecentUnlocks } from '../AchievementRecentUnlocks';

export const AchievementShowRoot: FC = () => {
const { achievement, backingGame, gameAchievementSet } =
Expand Down Expand Up @@ -144,7 +145,9 @@ export const AchievementShowRoot: FC = () => {
<AchievementCommentList />
</BaseTabsContent>

<BaseTabsContent value="unlocks">{'AchievementRecentUnlocks'}</BaseTabsContent>
<BaseTabsContent value="unlocks">
<AchievementRecentUnlocks />
</BaseTabsContent>

<BaseTabsContent value="changelog">{'AchievementChangelog'}</BaseTabsContent>
</BaseTabs>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { render, screen } from '@/test';
import { createAchievement, createAchievementRecentUnlock, createUser } from '@/test/factories';

import { AchievementRecentUnlocks } from './AchievementRecentUnlocks';

describe('Component: AchievementRecentUnlocks', () => {
it('renders without crashing', () => {
// ARRANGE
const { container } = render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 0 }),
recentUnlocks: [],
},
});

// ASSERT
expect(container).toBeTruthy();
});

it('given recentUnlocks is undefined, renders placeholder rows matching the count', () => {
// ARRANGE
render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 3 }),
recentUnlocks: undefined,
},
});

// ASSERT
expect(screen.getAllByRole('row')).toHaveLength(4); // 1 header + 3 placeholders
});

it('given unlocksTotal is undefined, treats it as 0 and shows an empty state message', () => {
// ARRANGE
render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: undefined }),
},
});

// ASSERT
expect(screen.getByText(/no unlocks found/i)).toBeVisible();
});

it('given unlocksTotal is 0, shows an empty state message without waiting for the deferred prop', () => {
// ARRANGE
render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 0 }),
},
});

// ASSERT
expect(screen.getByText(/no unlocks found/i)).toBeVisible();
});

it('given recentUnlocks resolves to an empty array, shows an empty state message', () => {
// ARRANGE
render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 5 }),
recentUnlocks: [],
},
});

// ASSERT
expect(screen.getByText(/no unlocks found/i)).toBeVisible();
});

it('given an unlock with isHardcore true, displays "Hardcore"', () => {
// ARRANGE
const unlock = createAchievementRecentUnlock({ isHardcore: true });

render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 1 }),
recentUnlocks: [unlock],
},
});

// ASSERT
expect(screen.getByText('Hardcore')).toBeVisible();
});

it('given an unlock with isHardcore false, does not display any mode label', () => {
// ARRANGE
const unlock = createAchievementRecentUnlock({ isHardcore: false });

render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 1 }),
recentUnlocks: [unlock],
},
});

// ASSERT
expect(screen.queryByText('Softcore')).not.toBeInTheDocument();
});

it('renders the user display name as a link', () => {
// ARRANGE
const user = createUser({ displayName: 'Scott' });
const unlock = createAchievementRecentUnlock({ user });

render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 1 }),
recentUnlocks: [unlock],
},
});

// ASSERT
const link = screen.getByRole('link', { name: /scott/i });
expect(link).toBeVisible();
expect(link).toHaveAttribute('href', expect.stringContaining('Scott'));
});

it('renders the unlock timestamp', () => {
// ARRANGE
const unlock = createAchievementRecentUnlock({
unlockedAt: new Date('2026-02-06T14:32:00Z').toISOString(),
isHardcore: false,
});

render(<AchievementRecentUnlocks />, {
pageProps: {
achievement: createAchievement({ unlocksTotal: 1 }),
recentUnlocks: [unlock],
},
});

// ASSERT
expect(screen.getByText(/Feb 6, 2026/i)).toBeVisible();
});
});
Loading