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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/components/leaderboard-page/entries-table.hbs
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)}} />
Copy link

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 from 33501 instead of continuing sequentially from the top half.

Fix in Cursor Fix in Web

{{/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>
38 changes: 38 additions & 0 deletions app/components/leaderboard-page/entries-table.ts
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;
}
}
7 changes: 7 additions & 0 deletions app/components/leaderboard-page/entries-table/filler-row.hbs
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>
13 changes: 13 additions & 0 deletions app/components/leaderboard-page/entries-table/filler-row.ts
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;
}
}
59 changes: 59 additions & 0 deletions app/components/leaderboard-page/entries-table/row.hbs
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}}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Score Trend Indicators Use Fixed Test Values

The score trend indicators and their tooltips are hardcoded to specific scores (142, 99) and fixed messages. This looks like test/demo code that was accidentally committed, so real score changes won't be reflected.

Fix in Cursor Fix in Web


<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>
23 changes: 23 additions & 0 deletions app/components/leaderboard-page/entries-table/row.ts
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';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Hardcoded Username Causes Incorrect Highlighting

The isCurrentUser getter hardcodes the username 'ry' instead of comparing against the actual authenticated user. This means only the 'ry' user's row will be highlighted, regardless of who is logged in. This looks like development code that was accidentally committed.

Fix in Cursor Fix in Web

}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'LeaderboardPage::EntriesTable::Row': typeof LeaderboardPageEntriesTableRow;
}
}
23 changes: 23 additions & 0 deletions app/components/leaderboard-page/header.hbs
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>
29 changes: 29 additions & 0 deletions app/components/leaderboard-page/header.ts
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;
}
}
18 changes: 18 additions & 0 deletions app/controllers/leaderboard.ts
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);
}
}
10 changes: 8 additions & 2 deletions app/models/leaderboard-entry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import type LeaderboardModel from './leaderboard';
import type UserModel from './user';
import CourseModel from './course';

export default class LeaderboardEntryModel extends Model {
@belongsTo('leaderboard', { async: false, inverse: 'entries' }) declare leaderboard: LeaderboardModel;
Expand All @@ -10,8 +11,13 @@ export default class LeaderboardEntryModel extends Model {
@attr('number') declare score: number;

// @ts-expect-error: empty transform not supported
@attr('') declare relatedLanguageSlugs: string[];
@attr('') declare relatedCourseSlugs: string[];

// @ts-expect-error: empty transform not supported
@attr('') declare relatedConceptSlugs: string[];
@attr('') declare relatedLanguageSlugs: string[];

get relatedCourses(): CourseModel[] {
const allCourses = this.store.peekAll('course');
return this.relatedCourseSlugs.map((slug) => allCourses.find((course) => course.slug === slug)).filter(Boolean);
}
}
1 change: 1 addition & 0 deletions app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Router.map(function () {
this.route('join'); // TODO: Add dark mode support
this.route('join-course', { path: '/join/:course_slug' });
this.route('join-track', { path: '/join-track/:track_slug' });
this.route('leaderboard', { path: '/leaderboards/:language_slug' });
this.route('login'); // TODO: Add dark mode support?
this.route('logged-in'); // TODO: Add dark mode support?
this.route('membership'); // TODO: Add dark mode support
Expand Down
50 changes: 50 additions & 0 deletions app/routes/leaderboard.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Model Hook Fails with Non-Null Assertions

The model hook uses non-null assertions (!) when finding a language by slug and accessing leaderboards[0]. If no language matches the provided slug or the leaderboards array is empty, these assertions will cause runtime errors.

Fix in Cursor Fix in Web

};
}
}
16 changes: 16 additions & 0 deletions app/styles/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,19 @@
.animate-spin-once {
animation: spin 1s 1;
}

.triangle-up {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 7px solid currentcolor;
}

.triangle-down {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid currentcolor;
}
11 changes: 11 additions & 0 deletions app/templates/leaderboard.hbs
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>
2 changes: 2 additions & 0 deletions mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import institutionMembershipGrantApplications from './handlers/institution-membe
import institutions from './handlers/institutions';
import languages from './handlers/languages';
import leaderboardEntries from './handlers/leaderboard-entries';
import leaderboards from './handlers/leaderboards';
import logstreams from './handlers/logstreams';
import onboardingSurveys from './handlers/onboarding-surveys';
import perks from './handlers/perks';
Expand Down Expand Up @@ -181,6 +182,7 @@ function routes() {
institutions(this);
languages(this);
leaderboardEntries(this);
leaderboards(this);
logstreams(this);
onboardingSurveys(this);
perks(this);
Expand Down
3 changes: 3 additions & 0 deletions mirage/handlers/leaderboards.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function (server) {
server.get('/leaderboards');
}
Loading
Loading