Skip to content

Commit 3fd2b3b

Browse files
committed
feat(leaderboard): add loading skeleton and simplify data flow
Add a skeleton row component with animated placeholders to improve UX during leaderboard data loading. Refactor EntriesTable to accept loading state and all relevant data as args, removing internal fetching and tracked state management. Update loading skeleton styles for better visual feedback. These changes decouple data loading from the table component and provide a smoother loading experience.
1 parent bd33085 commit 3fd2b3b

File tree

8 files changed

+169
-75
lines changed

8 files changed

+169
-75
lines changed

app/components/leaderboard-page/entries-table.hbs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
<div ...attributes>
2-
{{#if this.hasEntries}}
2+
{{#if (or this.hasEntries @isLoadingEntries)}}
33
<div>
44
<table class="min-w-full bg-white">
55
<thead>
66
<LeaderboardPage::EntriesTable::HeaderRow @language={{@language}} />
77
</thead>
88
<tbody>
9-
{{#each this.sortedTopEntries as |entry index|}}
10-
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @rankText={{concat "#" (add index 1)}} />
11-
{{/each}}
9+
{{#if @isLoadingEntries}}
10+
{{#each (range 1 11) as |rank|}}
11+
<LeaderboardPage::EntriesTable::SkeletonRow @rankText={{concat "#" rank}} />
12+
{{/each}}
13+
{{else}}
14+
{{#each this.sortedTopEntries as |entry index|}}
15+
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @rankText={{concat "#" (add index 1)}} />
16+
{{/each}}
1217

13-
{{#if this.loadUserSpecificResourcesTask.isRunning}}
14-
<LeaderboardPage::EntriesTable::FillerRow @text="... loading ..." />
15-
{{else if this.shouldShowSurroundingEntries}}
16-
<LeaderboardPage::EntriesTable::FillerRow @text="... other users ..." />
18+
{{#if this.shouldShowSurroundingEntries}}
19+
<LeaderboardPage::EntriesTable::FillerRow @text="... other users ..." />
1720

18-
{{#each this.sortedSurroundingEntriesWithRanks as |entryWithRank|}}
19-
<LeaderboardPage::EntriesTable::Row @entry={{entryWithRank.entry}} @rankText={{concat "#" entryWithRank.rank}} />
20-
{{/each}}
21+
{{#each this.sortedSurroundingEntriesWithRanks as |entryWithRank|}}
22+
<LeaderboardPage::EntriesTable::Row @entry={{entryWithRank.entry}} @rankText={{concat "#" entryWithRank.rank}} />
23+
{{/each}}
24+
{{/if}}
2125
{{/if}}
2226
</tbody>
2327
</table>

app/components/leaderboard-page/entries-table.ts

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,42 +12,34 @@ interface Signature {
1212
Element: HTMLDivElement;
1313

1414
Args: {
15+
isLoadingEntries: boolean;
1516
language: LanguageModel;
17+
surroundingEntries: LeaderboardEntryModel[];
1618
topEntries: LeaderboardEntryModel[];
19+
userRankCalculation: LeaderboardRankCalculationModel | null;
1720
};
1821
}
1922

2023
export default class LeaderboardPageEntriesTable extends Component<Signature> {
2124
@service declare authenticator: AuthenticatorService;
2225
@service declare store: Store;
2326

24-
@tracked surroundingEntries: LeaderboardEntryModel[] = [];
25-
@tracked userRankCalculation: LeaderboardRankCalculationModel | null = null;
26-
27-
constructor(owner: unknown, args: Signature['Args']) {
28-
super(owner, args);
29-
30-
if (this.authenticator.isAuthenticated) {
31-
this.loadUserSpecificResourcesTask.perform();
32-
}
33-
}
34-
3527
get hasEntries() {
3628
return this.args.topEntries.length > 0;
3729
}
3830

3931
get shouldShowSurroundingEntries(): boolean {
40-
return !this.userIsInTopLeaderboardEntries && this.surroundingEntries.length > 0;
32+
return !this.userIsInTopLeaderboardEntries && this.args.surroundingEntries.length > 0;
4133
}
4234

4335
get sortedSurroundingEntries() {
44-
return this.surroundingEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
36+
return this.args.surroundingEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
4537
}
4638

4739
get sortedSurroundingEntriesWithRanks() {
4840
return this.sortedSurroundingEntries.map((entry, index) => ({
4941
entry: entry,
50-
rank: this.userRankCalculation!.rank + (index - this.userEntryIndexInSurroundingEntries),
42+
rank: this.args.userRankCalculation!.rank + (index - this.userEntryIndexInSurroundingEntries),
5143
}));
5244
}
5345

@@ -66,41 +58,6 @@ export default class LeaderboardPageEntriesTable extends Component<Signature> {
6658

6759
return this.args.topEntries.some((entry) => entry.user.id === this.authenticator.currentUserId);
6860
}
69-
70-
loadUserSpecificResourcesTask = task({ keepLatest: true }, async (): Promise<void> => {
71-
if (this.userIsInTopLeaderboardEntries) {
72-
return;
73-
}
74-
75-
this.surroundingEntries = (await this.store.query('leaderboard-entry', {
76-
include: 'leaderboard,user',
77-
leaderboard_id: this.args.language.leaderboard!.id,
78-
user_id: this.authenticator.currentUserId, // Only used in tests since mirage doesn't have auth context
79-
filter_type: 'around_me',
80-
})) as unknown as LeaderboardEntryModel[];
81-
82-
if (this.surroundingEntries.length === 0) {
83-
return;
84-
}
85-
86-
const userRankCalculations = (await this.store.query('leaderboard-rank-calculation', {
87-
include: 'user',
88-
leaderboard_id: this.args.language.leaderboard!.id,
89-
user_id: this.authenticator.currentUserId, // Only used in tests since mirage doesn't have auth context
90-
})) as unknown as LeaderboardRankCalculationModel[];
91-
92-
this.userRankCalculation = userRankCalculations[0] || null;
93-
94-
// TODO: Also look at "outdated" user rank calculations?
95-
if (!this.userRankCalculation) {
96-
this.userRankCalculation = await this.store
97-
.createRecord('leaderboard-rank-calculation', {
98-
leaderboard: this.args.language.leaderboard!,
99-
user: this.authenticator.currentUser!,
100-
})
101-
.save();
102-
}
103-
});
10461
}
10562

10663
declare module '@glint/environment-ember-loose/registry' {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<tr class="hover:bg-gray-50 group/table-row" data-test-leaderboard-entry-skeleton-row>
2+
<LeaderboardPage::EntriesTable::RowCell class="text-right w-[8%]">
3+
<span class="text-xs font-medium text-gray-400">
4+
{{@rankText}}
5+
</span>
6+
</LeaderboardPage::EntriesTable::RowCell>
7+
8+
<LeaderboardPage::EntriesTable::RowCell>
9+
<div class="flex items-center justify-between gap-x-6">
10+
<div class="flex items-center gap-1.5">
11+
<div class="flex-shrink-0 flex h-6 w-6">
12+
<LoadingSkeleton @isCircle={{true}} />
13+
</div>
14+
<div class="text-xs font-mono max-w-[12ch] sm:max-w-[16ch] truncate">
15+
<LoadingSkeleton @width={{60}} />
16+
</div>
17+
</div>
18+
<div class="hidden md:flex items-center gap-1.5 flex-shrink-0 opacity-25 group-hover/table-row:opacity-100">
19+
{{#each (repeat 3)}}
20+
<div class="flex h-4 w-4">
21+
<LoadingSkeleton @isCircle={{true}} />
22+
</div>
23+
{{/each}}
24+
</div>
25+
</div>
26+
</LeaderboardPage::EntriesTable::RowCell>
27+
28+
<LeaderboardPage::EntriesTable::RowCell class="hidden md:table-cell">
29+
<div class="flex items-center justify-end gap-2">
30+
<LoadingSkeleton @width={{20}} />
31+
<span class="text-gray-400 text-xs font-mono">
32+
stages
33+
</span>
34+
</div>
35+
</LeaderboardPage::EntriesTable::RowCell>
36+
37+
<LeaderboardPage::EntriesTable::RowCell>
38+
<div class="flex items-center justify-end gap-2">
39+
<LoadingSkeleton @width={{20}} />
40+
<span class="text-gray-400 text-xs font-mono">
41+
pts
42+
</span>
43+
</div>
44+
</LeaderboardPage::EntriesTable::RowCell>
45+
</tr>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Component from '@glimmer/component';
2+
3+
interface Signature {
4+
Element: HTMLTableRowElement;
5+
}
6+
7+
export default class LeaderboardPageEntriesTableSkeletonRow extends Component<Signature> {}
8+
9+
declare module '@glint/environment-ember-loose/registry' {
10+
export default interface Registry {
11+
'LeaderboardPage::EntriesTable::SkeletonRow': typeof LeaderboardPageEntriesTableSkeletonRow;
12+
}
13+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
<div class="bg-gray-200 h-4 {{if @isCircle 'rounded-full' 'rounded-sm'}}" style={{html-safe this.widthStyle}} ...attributes>
1+
<div class="bg-gray-200 animate-pulse {{if @isCircle 'rounded-full' 'rounded-sm h-4'}}" style={{html-safe this.widthStyle}} ...attributes>
22
</div>

app/controllers/leaderboard.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,94 @@
11
import Controller from '@ember/controller';
2+
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
23
import type LanguageModel from 'codecrafters-frontend/models/language';
4+
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry';
5+
import type LeaderboardModel from 'codecrafters-frontend/models/leaderboard';
36
import type Store from '@ember-data/store';
47
import type { ModelType } from 'codecrafters-frontend/routes/leaderboard';
58
import { inject as service } from '@ember/service';
9+
import { task } from 'ember-concurrency';
10+
import { tracked } from '@glimmer/tracking';
11+
import type LeaderboardRankCalculationModel from 'codecrafters-frontend/models/leaderboard-rank-calculation';
612

713
export default class LeaderboardController extends Controller {
14+
@service declare authenticator: AuthenticatorService;
815
@service declare store: Store;
916

17+
@tracked surroundingEntries: LeaderboardEntryModel[] = [];
18+
@tracked topEntries: LeaderboardEntryModel[] = [];
19+
@tracked userRankCalculation: LeaderboardRankCalculationModel | null = null;
20+
1021
declare model: ModelType;
1122

23+
get allEntries(): LeaderboardEntryModel[] {
24+
return [...this.surroundingEntries, ...this.topEntries];
25+
}
26+
27+
get isLoadingEntries(): boolean {
28+
return this.loadEntriesTask.isRunning;
29+
}
30+
31+
get leaderboard(): LeaderboardModel {
32+
return this.model.language.leaderboard!;
33+
}
34+
1235
get sortedLanguagesForDropdown(): LanguageModel[] {
1336
return this.store
1437
.peekAll('language')
1538
.sortBy('sortPositionForTrack')
1639
.filter((language) => language.liveOrBetaStagesCount > 0);
1740
}
41+
42+
get userEntry(): LeaderboardEntryModel | undefined {
43+
return this.allEntries.find((entry) => entry.user.id === this.authenticator.currentUserId);
44+
}
45+
46+
get userIsInTopLeaderboardEntries(): boolean {
47+
if (!this.authenticator.isAuthenticated) {
48+
return false;
49+
}
50+
51+
return this.topEntries.some((entry) => entry.user.id === this.authenticator.currentUserId);
52+
}
53+
54+
loadEntriesTask = task({ restartable: true }, async () => {
55+
this.topEntries = (await this.store.query('leaderboard-entry', {
56+
include: 'leaderboard,user',
57+
leaderboard_id: this.leaderboard.id,
58+
filter_type: 'top',
59+
})) as unknown as LeaderboardEntryModel[];
60+
61+
if (!this.authenticator.isAuthenticated) {
62+
return;
63+
}
64+
65+
this.surroundingEntries = (await this.store.query('leaderboard-entry', {
66+
include: 'leaderboard,user',
67+
leaderboard_id: this.leaderboard.id,
68+
user_id: this.authenticator.currentUserId, // Only used in tests since mirage doesn't have auth context
69+
filter_type: 'around_me',
70+
})) as unknown as LeaderboardEntryModel[];
71+
72+
if (this.surroundingEntries.length === 0) {
73+
return;
74+
}
75+
76+
const userRankCalculations = (await this.store.query('leaderboard-rank-calculation', {
77+
include: 'user',
78+
leaderboard_id: this.model.language.leaderboard!.id,
79+
user_id: this.authenticator.currentUserId, // Only used in tests since mirage doesn't have auth context
80+
})) as unknown as LeaderboardRankCalculationModel[];
81+
82+
this.userRankCalculation = userRankCalculations[0] || null;
83+
84+
// TODO: Also look at "outdated" user rank calculations?
85+
if (!this.userRankCalculation) {
86+
this.userRankCalculation = await this.store
87+
.createRecord('leaderboard-rank-calculation', {
88+
leaderboard: this.model.language.leaderboard!,
89+
user: this.authenticator.currentUser!,
90+
})
91+
.save();
92+
}
93+
});
1894
}

app/routes/leaderboard.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,12 @@ import scrollToTop from 'codecrafters-frontend/utils/scroll-to-top';
33
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
44
import type CourseModel from 'codecrafters-frontend/models/course';
55
import type LanguageModel from 'codecrafters-frontend/models/language';
6-
import type LeaderboardModel from 'codecrafters-frontend/models/leaderboard';
76
import type Store from '@ember-data/store';
87
import { inject as service } from '@ember/service';
9-
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry';
108
import RouteInfoMetadata from 'codecrafters-frontend/utils/route-info-metadata';
119

1210
export type ModelType = {
1311
language: LanguageModel;
14-
leaderboard: LeaderboardModel;
15-
topLeaderboardEntries: LeaderboardEntryModel[];
1612
};
1713

1814
export default class LeaderboardRoute extends BaseRoute {
@@ -39,16 +35,8 @@ export default class LeaderboardRoute extends BaseRoute {
3935

4036
const language = languages.find((language) => language.slug === params.language_slug)!;
4137

42-
const topLeaderboardEntries = (await this.store.query('leaderboard-entry', {
43-
include: 'leaderboard,user',
44-
leaderboard_id: language.leaderboard!.id,
45-
filter_type: 'top',
46-
})) as unknown as LeaderboardEntryModel[];
47-
4838
return {
4939
language: language,
50-
leaderboard: language.leaderboard!,
51-
topLeaderboardEntries: topLeaderboardEntries,
5240
};
5341
}
5442
}

app/templates/leaderboard.hbs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
<div class="container mx-auto lg:max-w-(--breakpoint-lg) px-3 md:px-6 py-6 md:py-10 pb-32 md:pb-32">
1+
<div
2+
class="container mx-auto lg:max-w-(--breakpoint-lg) px-3 md:px-6 py-6 md:py-10 pb-32 md:pb-32"
3+
{{did-insert this.loadEntriesTask.perform}}
4+
{{did-update this.loadEntriesTask.perform this.leaderboard.id}}
5+
>
26
<LeaderboardPage::Header @selectedLanguage={{@model.language}} class="mb-6" />
37

48
{{! <RoadmapInfoAlert @heading="What are challenges?" class="hidden md:block mb-6">
59
Challenges are step-by-step coding exercises where you build projects from scratch. Vote and help us decide which challenges to build.
610
</RoadmapInfoAlert> }}
711

812
<div class="flex items-start gap-8">
9-
<LeaderboardPage::EntriesTable @language={{@model.language}} @topEntries={{@model.topLeaderboardEntries}} class="flex-grow" />
13+
<LeaderboardPage::EntriesTable
14+
@isLoadingEntries={{this.isLoadingEntries}}
15+
@language={{@model.language}}
16+
@surroundingEntries={{this.surroundingEntries}}
17+
@topEntries={{this.topEntries}}
18+
@userRankCalculation={{this.userRankCalculation}}
19+
class="flex-grow"
20+
/>
1021
</div>
1122
</div>

0 commit comments

Comments
 (0)