- Notifications
You must be signed in to change notification settings - Fork 23
feat: add leaderboard feature with language dropdown and entries table #3060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
<div ...attributes> | ||
{{#if this.hasEntries}} | ||
<div> | ||
<table class="min-w-full bg-white"> | ||
<thead> | ||
<tr> | ||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border border-gray-200"> | ||
Rank | ||
</th> | ||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border border-gray-200"> | ||
User | ||
</th> | ||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider border border-gray-200"> | ||
Stages Completed | ||
</th> | ||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider border border-gray-200"> | ||
Score | ||
</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
{{#each this.sortedTopHalfEntries as |entry index|}} | ||
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @index={{index}} /> | ||
{{/each}} | ||
| ||
<LeaderboardPage::EntriesTable::FillerRow /> | ||
| ||
{{#each this.sortedBottomHalfEntries as |entry index|}} | ||
<LeaderboardPage::EntriesTable::Row @entry={{entry}} @index={{(add 33501 index)}} /> | ||
{{/each}} | ||
</tbody> | ||
</table> | ||
</div> | ||
{{else}} | ||
<div class="text-center py-12"> | ||
<div class="text-gray-500 text-sm"> | ||
No entries found for this leaderboard. | ||
</div> | ||
</div> | ||
{{/if}} | ||
</div> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import Component from '@glimmer/component'; | ||
import { inject as service } from '@ember/service'; | ||
import type Store from '@ember-data/store'; | ||
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry'; | ||
| ||
interface Signature { | ||
Element: HTMLDivElement; | ||
| ||
Args: { | ||
entries: LeaderboardEntryModel[]; | ||
}; | ||
} | ||
| ||
export default class LeaderboardPageEntriesTable extends Component<Signature> { | ||
@service declare store: Store; | ||
| ||
get hasEntries() { | ||
return this.sortedEntries.length > 0; | ||
} | ||
| ||
get sortedEntries() { | ||
return this.args.entries.filter((entry) => !entry.isBanned).sort((a, b) => b.score - a.score); | ||
} | ||
| ||
get sortedBottomHalfEntries() { | ||
return this.sortedEntries.slice(Math.floor(this.sortedEntries.length / 2)); | ||
} | ||
| ||
get sortedTopHalfEntries() { | ||
return this.sortedEntries.slice(0, Math.floor(this.sortedEntries.length / 2)); | ||
} | ||
} | ||
| ||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'LeaderboardPage::EntriesTable': typeof LeaderboardPageEntriesTable; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
<tr class="bg-gray-50"> | ||
<td colspan="4" class="px-4 py-3 whitespace-nowrap border border-gray-200 text-center"> | ||
<div class="text-xs text-gray-500"> | ||
... other users ... | ||
</div> | ||
</td> | ||
</tr> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import Component from '@glimmer/component'; | ||
| ||
interface Signature { | ||
Element: HTMLTableRowElement; | ||
} | ||
| ||
export default class LeaderboardPageEntriesTableFillerRow extends Component<Signature> {} | ||
| ||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'LeaderboardPage::EntriesTable::FillerRow': typeof LeaderboardPageEntriesTableFillerRow; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
<tr class="{{if this.isCurrentUser 'bg-teal-50 ring ring-teal-500 relative' 'hover:bg-gray-50'}} group/table-row"> | ||
<td | ||
class="px-4 py-2 whitespace-nowrap text-xs font-medium | ||
{{if this.isCurrentUser 'text-teal-600' 'text-gray-400'}} | ||
text-right border border-gray-200 w-[8%]" | ||
> | ||
#{{add @index 1}} | ||
</td> | ||
<td class="px-4 py-2 whitespace-nowrap border border-gray-200"> | ||
<div class="flex items-center justify-between gap-x-6 flex-wrap"> | ||
<div class="flex items-center gap-1.5"> | ||
<div class="flex-shrink-0 h-6 w-6"> | ||
<AvatarImage @user={{@entry.user}} class="h-6 w-6 rounded-full border border-gray-300" /> | ||
</div> | ||
<div class="text-xs font-mono {{if this.isCurrentUser 'text-teal-600' 'text-gray-600'}}"> | ||
<LinkTo @route="user" @model={{@entry.user.username}} class="hover:underline hover:text-gray-800"> | ||
{{@entry.user.username}} | ||
</LinkTo> | ||
</div> | ||
</div> | ||
<div class="flex items-center gap-1.5 flex-shrink-0 {{unless this.isCurrentUser 'opacity-25 group-hover/table-row:opacity-100'}}"> | ||
{{#each @entry.relatedCourses as |course|}} | ||
<div class="flex"> | ||
<CourseLogo @course={{course}} class="h-4 w-4 {{unless this.isCurrentUser 'grayscale opacity-50 hover:opacity-100 hover:grayscale-0'}}" /> | ||
<EmberTooltip @text={{course.name}} /> | ||
</div> | ||
{{/each}} | ||
</div> | ||
</div> | ||
</td> | ||
<td class="px-4 py-2 whitespace-nowrap border border-gray-200"> | ||
<div class="flex items-center justify-end"> | ||
<div class="text-xs font-mono"> | ||
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-700'}}">{{@entry.score}}</span> | ||
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-400'}}">stages</span> | ||
</div> | ||
</div> | ||
</td> | ||
<td class="px-4 py-2 whitespace-nowrap border border-gray-200"> | ||
<div class="flex items-center justify-end"> | ||
{{#if (eq @entry.score 142)}} | ||
<span class="inline-block align-middle text-teal-500 triangle-up mr-1.5" aria-hidden="true"> | ||
<EmberTooltip @text="Increased from 139 to 142 in the past month" /> | ||
</span> | ||
{{/if}} | ||
| ||
{{#if (eq @entry.score 99)}} | ||
<span class="inline-block align-middle text-red-500 triangle-down mr-1.5" aria-hidden="true"> | ||
<EmberTooltip @text="Decreased from 199 to 99 in the past month" /> | ||
</span> | ||
{{/if}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. | ||
| ||
<div class="text-xs font-mono"> | ||
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-700'}}">{{@entry.score}}</span> | ||
<span class="{{if this.isCurrentUser 'text-teal-600' 'text-gray-400'}}">pts</span> | ||
</div> | ||
</div> | ||
</td> | ||
</tr> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import Component from '@glimmer/component'; | ||
import type LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry'; | ||
| ||
interface Signature { | ||
Element: HTMLTableRowElement; | ||
| ||
Args: { | ||
entry: LeaderboardEntryModel; | ||
index: number; | ||
}; | ||
} | ||
| ||
export default class LeaderboardPageEntriesTableRow extends Component<Signature> { | ||
get isCurrentUser(): boolean { | ||
return this.args.entry.user.username === 'ry'; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Hardcoded Username Causes Incorrect HighlightingThe | ||
} | ||
| ||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'LeaderboardPage::EntriesTable::Row': typeof LeaderboardPageEntriesTableRow; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
<div class="flex justify-between" ...attributes> | ||
<div class="py-3"> | ||
<h3 class="text-2xl font-bold text-gray-800" data-test-leaderboard-title> | ||
{{@selectedLanguage.name}} | ||
Leaderboard | ||
</h3> | ||
<div class="text-xs text-gray-400" data-test-leaderboard-description> | ||
Leaderboard for | ||
{{@selectedLanguage.name}} | ||
users | ||
</div> | ||
</div> | ||
| ||
<LanguageDropdown | ||
@languages={{this.sortedLanguagesForDropdown}} | ||
@requestedLanguage={{@selectedLanguage}} | ||
@selectedLanguage={{@selectedLanguage}} | ||
@shouldShowAllLanguagesOption={{false}} | ||
@onRequestedLanguageChange={{(noop)}} | ||
@onDidInsertDropdown={{(noop)}} | ||
class="py-3" | ||
/> | ||
</div> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import Component from '@glimmer/component'; | ||
import { inject as service } from '@ember/service'; | ||
import type Store from '@ember-data/store'; | ||
import type LanguageModel from 'codecrafters-frontend/models/language'; | ||
| ||
interface Signature { | ||
Element: HTMLDivElement; | ||
| ||
Args: { | ||
selectedLanguage: LanguageModel; | ||
}; | ||
} | ||
| ||
export default class LeaderboardPageHeader extends Component<Signature> { | ||
@service declare store: Store; | ||
| ||
get sortedLanguagesForDropdown(): LanguageModel[] { | ||
return this.store | ||
.peekAll('language') | ||
.sortBy('sortPositionForTrack') | ||
.filter((language) => language.stagesCount > 0); | ||
} | ||
} | ||
| ||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'LeaderboardPage::Header': typeof LeaderboardPageHeader; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import Controller from '@ember/controller'; | ||
import type LanguageModel from 'codecrafters-frontend/models/language'; | ||
import type Store from '@ember-data/store'; | ||
import type { ModelType } from 'codecrafters-frontend/routes/leaderboard'; | ||
import { inject as service } from '@ember/service'; | ||
| ||
export default class LeaderboardController extends Controller { | ||
@service declare store: Store; | ||
| ||
declare model: ModelType; | ||
| ||
get sortedLanguagesForDropdown(): LanguageModel[] { | ||
return this.store | ||
.peekAll('language') | ||
.sortBy('sortPositionForTrack') | ||
.filter((language) => language.stagesCount > 0); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import BaseRoute from 'codecrafters-frontend/utils/base-route'; | ||
import scrollToTop from 'codecrafters-frontend/utils/scroll-to-top'; | ||
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator'; | ||
import type CourseModel from 'codecrafters-frontend/models/course'; | ||
import type LanguageModel from 'codecrafters-frontend/models/language'; | ||
import type LeaderboardModel from 'codecrafters-frontend/models/leaderboard'; | ||
import type Store from '@ember-data/store'; | ||
import { inject as service } from '@ember/service'; | ||
| ||
export type ModelType = { | ||
language: LanguageModel; | ||
leaderboard: LeaderboardModel; | ||
}; | ||
| ||
export default class LeaderboardRoute extends BaseRoute { | ||
@service declare authenticator: AuthenticatorService; | ||
@service declare store: Store; | ||
| ||
activate(): void { | ||
scrollToTop(); | ||
} | ||
| ||
afterModel(_model: ModelType): void { | ||
if (!this.authenticator.currentUser?.isStaff) { | ||
this.router.transitionTo('not-found'); | ||
| ||
return; | ||
} | ||
} | ||
| ||
async model(params: { language_slug: string }): Promise<ModelType> { | ||
(await this.store.findAll('course', { | ||
include: 'extensions,stages,language-configurations.language', | ||
})) as unknown as CourseModel[]; | ||
| ||
(await this.store.findAll('language', { | ||
include: 'primer-concept-group,primer-concept-group.author,primer-concept-group.concepts,primer-concept-group.concepts.author', | ||
})) as unknown as LanguageModel[]; | ||
| ||
await this.authenticator.authenticate(); | ||
| ||
const language = this.store.peekAll('language').find((language) => language.slug === params.language_slug)!; | ||
const leaderboards = (await this.store.findAll('leaderboard', { include: 'entries,entries.user' })) as unknown as LeaderboardModel[]; | ||
| ||
return { | ||
language: language, | ||
leaderboard: leaderboards[0]!, // TODO: Support actual filter of leaderboards | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<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"> | ||
<LeaderboardPage::Header @selectedLanguage={{@model.language}} class="mb-6" /> | ||
| ||
{{! <RoadmapInfoAlert @heading="What are challenges?" class="hidden md:block mb-6"> | ||
Challenges are step-by-step coding exercises where you build projects from scratch. Vote and help us decide which challenges to build. | ||
</RoadmapInfoAlert> }} | ||
| ||
<div class="flex items-start gap-8"> | ||
<LeaderboardPage::EntriesTable @entries={{@model.leaderboard.entries}} class="flex-grow" /> | ||
</div> | ||
</div> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export default function (server) { | ||
server.get('/leaderboards'); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Leaderboard Rank Calculation Error
The rank calculation for bottom half leaderboard entries uses a hardcoded offset of
33501
. This causes these entries to display incorrect ranks, starting from33501
instead of continuing sequentially from the top half.