Skip to content

Commit a75c45b

Browse files
authored
Merge pull request #3071 from codecrafters-io/add-leaderboard-feature-and-enhancements
feat: add leaderboard feature with language dropdown and entries table
2 parents 034d8bd + 472405b commit a75c45b

File tree

22 files changed

+663
-11
lines changed

22 files changed

+663
-11
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<div ...attributes>
2+
{{#if this.hasEntries}}
3+
<div>
4+
<table class="min-w-full bg-white">
5+
<thead>
6+
<tr>
7+
<LeaderboardPage::EntriesTable::HeaderRowCell @title="Rank" @alignment="left" />
8+
<LeaderboardPage::EntriesTable::HeaderRowCell @title="User" @alignment="left" />
9+
10+
<LeaderboardPage::EntriesTable::HeaderRowCell
11+
@title="Stages Completed"
12+
@alignment="right"
13+
class="hidden md:table-cell"
14+
@explanationMarkdown={{this.explanationMarkdownForStagesCompleted}}
15+
/>
16+
17+
<LeaderboardPage::EntriesTable::HeaderRowCell
18+
@title="Score"
19+
@alignment="right"
20+
@explanationMarkdown={{this.explanationMarkdownForScore}}
21+
/>
22+
</tr>
23+
</thead>
24+
<tbody>
25+
{{#each this.sortedTopEntries as |entry index|}}
26+
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @rankText={{concat "#" (add index 1)}} />
27+
{{/each}}
28+
29+
{{#if this.shouldShowSurroundingEntries}}
30+
<LeaderboardPage::EntriesTable::FillerRow />
31+
32+
{{#each this.sortedSurroundingEntries as |entry|}}
33+
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @rankText={{"#~5000"}} />
34+
{{/each}}
35+
{{/if}}
36+
</tbody>
37+
</table>
38+
</div>
39+
{{else}}
40+
<div class="text-center py-12">
41+
<div class="text-gray-500 text-sm">
42+
No entries found for this leaderboard.
43+
</div>
44+
</div>
45+
{{/if}}
46+
</div>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Component from '@glimmer/component';
2+
import { inject as service } from '@ember/service';
3+
import type Store from '@ember-data/store';
4+
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry';
5+
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
6+
import type LanguageModel from 'codecrafters-frontend/models/language';
7+
8+
interface Signature {
9+
Element: HTMLDivElement;
10+
11+
Args: {
12+
language: LanguageModel;
13+
surroundingEntries: LeaderboardEntryModel[];
14+
topEntries: LeaderboardEntryModel[];
15+
};
16+
}
17+
18+
export default class LeaderboardPageEntriesTable extends Component<Signature> {
19+
@service declare authenticator: AuthenticatorService;
20+
@service declare store: Store;
21+
22+
get explanationMarkdownForScore() {
23+
return `
24+
The highest possible score for this track is ${this.args.language.leaderboard!.highestPossibleScore}.
25+
26+
Harder stages have higher scores assigned to them.
27+
`.trim();
28+
}
29+
30+
get explanationMarkdownForStagesCompleted() {
31+
return `There are ${this.args.language.stagesCount} stages available in this track.`;
32+
}
33+
34+
get hasEntries() {
35+
return this.args.topEntries.length > 0;
36+
}
37+
38+
get shouldShowSurroundingEntries(): boolean {
39+
return !!(this.authenticator.isAuthenticated && !this.userIsInTopLeaderboardEntries && this.sortedSurroundingEntries.length > 0);
40+
}
41+
42+
get sortedSurroundingEntries() {
43+
return this.args.surroundingEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
44+
}
45+
46+
get sortedTopEntries() {
47+
return this.args.topEntries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score);
48+
}
49+
50+
get userIsInTopLeaderboardEntries(): boolean {
51+
if (!this.authenticator.isAuthenticated) {
52+
return false;
53+
}
54+
55+
return this.args.topEntries.some((entry) => entry.user.id === this.authenticator.currentUserId);
56+
}
57+
}
58+
59+
declare module '@glint/environment-ember-loose/registry' {
60+
export default interface Registry {
61+
'LeaderboardPage::EntriesTable': typeof LeaderboardPageEntriesTable;
62+
}
63+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<tr class="bg-gray-50">
2+
<td colspan="4" class="px-4 py-3 whitespace-nowrap border border-gray-200 text-center">
3+
<div class="text-xs text-gray-500">
4+
... other users ...
5+
</div>
6+
</td>
7+
</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 LeaderboardPageEntriesTableFillerRow extends Component<Signature> {}
8+
9+
declare module '@glint/environment-ember-loose/registry' {
10+
export default interface Registry {
11+
'LeaderboardPage::EntriesTable::FillerRow': typeof LeaderboardPageEntriesTableFillerRow;
12+
}
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<th scope="col" class="font-normal px-4 py-3 border border-gray-200" ...attributes>
2+
<div class="flex items-center gap-1 {{if (eq @alignment 'left') 'justify-start' 'justify-end'}}">
3+
<span class="text-xs font-medium text-gray-500 uppercase tracking-wider">
4+
{{@title}}
5+
</span>
6+
7+
{{#if @explanationMarkdown}}
8+
<span>
9+
{{svg-jar "information-circle" class="w-4 h-4 fill-current text-gray-200 hover:text-gray-300"}}
10+
11+
<EmberTooltip>
12+
<div class="prose prose-sm prose-compact prose-invert text-white">
13+
{{markdown-to-html @explanationMarkdown}}
14+
</div>
15+
</EmberTooltip>
16+
</span>
17+
{{/if}}
18+
</div>
19+
</th>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Component from '@glimmer/component';
2+
3+
interface Signature {
4+
Element: HTMLTableCellElement;
5+
6+
Args: {
7+
alignment: 'left' | 'right';
8+
explanationMarkdown?: string;
9+
title: string;
10+
};
11+
}
12+
13+
export default class HeaderRowCell extends Component<Signature> {}
14+
15+
declare module '@glint/environment-ember-loose/registry' {
16+
export default interface Registry {
17+
'LeaderboardPage::EntriesTable::HeaderRowCell': typeof HeaderRowCell;
18+
}
19+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<td class="px-4 py-2 whitespace-nowrap border border-gray-200" ...attributes>
2+
{{yield}}
3+
</td>
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: HTMLTableCellElement;
5+
6+
Blocks: {
7+
default: [];
8+
};
9+
}
10+
11+
export default class RowCell extends Component<Signature> {}
12+
13+
declare module '@glint/environment-ember-loose/registry' {
14+
export default interface Registry {
15+
'LeaderboardPage::EntriesTable::RowCell': typeof RowCell;
16+
}
17+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<tr class="{{if this.isCurrentUser 'bg-teal-50 ring ring-teal-500 relative' 'hover:bg-gray-50'}} group/table-row" data-test-leaderboard-entry-row>
2+
<LeaderboardPage::EntriesTable::RowCell class="text-right w-[8%]">
3+
<span class="text-xs font-medium {{if this.isCurrentUser 'text-teal-600' '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 h-6 w-6">
12+
<AvatarImage @user={{@entry.user}} class="h-6 w-6 rounded-full border border-gray-300" />
13+
</div>
14+
<div class="text-xs font-mono max-w-[12ch] sm:max-w-[16ch] truncate {{if this.isCurrentUser 'text-teal-600' 'text-gray-600'}}">
15+
<a href={{@entry.user.codecraftersProfileUrl}} target="_blank" class="hover:underline hover:text-gray-800" rel="noopener noreferrer">
16+
{{@entry.user.username}}
17+
</a>
18+
</div>
19+
</div>
20+
<div class="hidden md:flex items-center gap-1.5 flex-shrink-0 {{unless this.isCurrentUser 'opacity-25 group-hover/table-row:opacity-100'}}">
21+
{{#if (gt this.hiddenCourses.length 0)}}
22+
<div class="text-gray-500">
23+
...
24+
25+
<EmberTooltip @text={{concat this.hiddenCourses.length " other " (pluralize "challenge" count=this.hiddenCourses.length)}} />
26+
</div>
27+
{{/if}}
28+
29+
{{#each this.visibleCourses as |course|}}
30+
<div class="flex">
31+
<CourseLogo @course={{course}} class="h-4 w-4 {{unless this.isCurrentUser 'grayscale opacity-50 hover:opacity-100 hover:grayscale-0'}}" />
32+
<EmberTooltip @text={{course.name}} />
33+
</div>
34+
{{/each}}
35+
</div>
36+
</div>
37+
</LeaderboardPage::EntriesTable::RowCell>
38+
39+
<LeaderboardPage::EntriesTable::RowCell class="hidden md:table-cell">
40+
<div class="flex items-center justify-end">
41+
<div class="text-xs font-mono">
42+
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-700'}}">{{@entry.scoreUpdatesCount}}</span>
43+
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-400'}}">stages</span>
44+
</div>
45+
</div>
46+
</LeaderboardPage::EntriesTable::RowCell>
47+
48+
<LeaderboardPage::EntriesTable::RowCell>
49+
<div class="flex items-center justify-end">
50+
{{!-- {{#if (eq @entry.score 142)}}
51+
<span class="inline-block align-middle text-teal-500 triangle-up mr-1.5" aria-hidden="true">
52+
<EmberTooltip @text="Increased from 139 to 142 in the past month" />
53+
</span>
54+
{{/if}} --}}
55+
56+
{{!-- {{#if (eq @entry.score 99)}}
57+
<span class="inline-block align-middle text-red-500 triangle-down mr-1.5" aria-hidden="true">
58+
<EmberTooltip @text="Decreased from 199 to 99 in the past month" />
59+
</span>
60+
{{/if}} --}}
61+
62+
<div class="text-xs font-mono">
63+
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-700'}}">{{@entry.score}}</span>
64+
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-400'}}">pts</span>
65+
</div>
66+
</div>
67+
</LeaderboardPage::EntriesTable::RowCell>
68+
</tr>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Component from '@glimmer/component';
2+
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry';
3+
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
4+
import { inject as service } from '@ember/service';
5+
import type CourseModel from 'codecrafters-frontend/models/course';
6+
7+
interface Signature {
8+
Element: HTMLTableRowElement;
9+
10+
Args: {
11+
entry: LeaderboardEntryModel;
12+
rankText: string;
13+
};
14+
}
15+
16+
const MAX_VISIBLE_COURSES = 7;
17+
18+
export default class LeaderboardPageEntriesTableRow extends Component<Signature> {
19+
@service declare authenticator: AuthenticatorService;
20+
21+
get hiddenCourses(): CourseModel[] {
22+
return this.args.entry.relatedCourses.slice(0, -MAX_VISIBLE_COURSES);
23+
}
24+
25+
get isCurrentUser(): boolean {
26+
return this.args.entry.user === this.authenticator.currentUser;
27+
}
28+
29+
get visibleCourses(): CourseModel[] {
30+
return this.args.entry.relatedCourses.slice(-MAX_VISIBLE_COURSES);
31+
}
32+
}
33+
34+
declare module '@glint/environment-ember-loose/registry' {
35+
export default interface Registry {
36+
'LeaderboardPage::EntriesTable::Row': typeof LeaderboardPageEntriesTableRow;
37+
}
38+
}

0 commit comments

Comments
 (0)