Skip to content

Commit 783e97f

Browse files
authored
Merge pull request #3084 from codecrafters-io/fix-leaderboard-user-data-loading
fix(leaderboard): load user data only when user is off top list
2 parents 09502a3 + a146106 commit 783e97f

File tree

11 files changed

+227
-72
lines changed

11 files changed

+227
-72
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 16) 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 & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,49 +5,39 @@ import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard
55
import type LeaderboardRankCalculationModel from 'codecrafters-frontend/models/leaderboard-rank-calculation';
66
import type Store from '@ember-data/store';
77
import { inject as service } from '@ember/service';
8-
import { task } from 'ember-concurrency';
9-
import { tracked } from '@glimmer/tracking';
108

119
interface Signature {
1210
Element: HTMLDivElement;
1311

1412
Args: {
13+
isLoadingEntries: boolean;
1514
language: LanguageModel;
15+
surroundingEntries: LeaderboardEntryModel[];
1616
topEntries: LeaderboardEntryModel[];
17+
userRankCalculation: LeaderboardRankCalculationModel | null;
1718
};
1819
}
1920

2021
export default class LeaderboardPageEntriesTable extends Component<Signature> {
2122
@service declare authenticator: AuthenticatorService;
2223
@service declare store: Store;
2324

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-
3525
get hasEntries() {
3626
return this.args.topEntries.length > 0;
3727
}
3828

3929
get shouldShowSurroundingEntries(): boolean {
40-
return !this.userIsInTopLeaderboardEntries && this.surroundingEntries.length > 0;
30+
return !this.userIsInTopLeaderboardEntries && this.args.surroundingEntries.length > 0;
4131
}
4232

4333
get sortedSurroundingEntries() {
44-
return this.surroundingEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
34+
return this.args.surroundingEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
4535
}
4636

4737
get sortedSurroundingEntriesWithRanks() {
4838
return this.sortedSurroundingEntries.map((entry, index) => ({
4939
entry: entry,
50-
rank: this.userRankCalculation!.rank + (index - this.userEntryIndexInSurroundingEntries),
40+
rank: this.args.userRankCalculation!.rank + (index - this.userEntryIndexInSurroundingEntries),
5141
}));
5242
}
5343

@@ -66,35 +56,6 @@ export default class LeaderboardPageEntriesTable extends Component<Signature> {
6656

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

10061
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 6)}}
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Component from '@glimmer/component';
2+
3+
interface Signature {
4+
Element: HTMLTableRowElement;
5+
6+
Args: {
7+
rankText: string;
8+
};
9+
}
10+
11+
export default class LeaderboardPageEntriesTableSkeletonRow extends Component<Signature> {}
12+
13+
declare module '@glint/environment-ember-loose/registry' {
14+
export default interface Registry {
15+
'LeaderboardPage::EntriesTable::SkeletonRow': typeof LeaderboardPageEntriesTableSkeletonRow;
16+
}
17+
}
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/components/loading-skeleton.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ export default class LoadingSkeleton extends Component<Signature> {
2020
}
2121
}
2222
}
23+
24+
declare module '@glint/environment-ember-loose/registry' {
25+
export default interface Registry {
26+
LoadingSkeleton: typeof LoadingSkeleton;
27+
}
28+
}

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 || this.userIsInTopLeaderboardEntries) {
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>

mirage/handlers/leaderboard-rank-calculations.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ export default function (server) {
99

1010
server.post('/leaderboard-rank-calculations', function (schema) {
1111
const attrs = this.normalizedRequestAttrs();
12+
const leaderboard = schema.leaderboards.find(attrs.leaderboardId);
13+
const user = schema.users.find(attrs.userId);
14+
15+
if (!leaderboard) {
16+
throw new Error('Leaderboard not found');
17+
}
18+
19+
if (!user) {
20+
throw new Error('User not found');
21+
}
22+
23+
if (!leaderboard.entries.models.find((entry) => entry.user.id === user.id)) {
24+
throw new Error('User does not have a leaderboard entry');
25+
}
26+
1227
attrs.calculatedAt = new Date();
1328
attrs.rank = 37812;
1429

0 commit comments

Comments
 (0)