diff --git a/app/components/leaderboard-page/entries-table.hbs b/app/components/leaderboard-page/entries-table.hbs new file mode 100644 index 0000000000..d87ce00a4e --- /dev/null +++ b/app/components/leaderboard-page/entries-table.hbs @@ -0,0 +1,41 @@ +
+ {{#if this.hasEntries}} +
+ + + + + + + + + + + {{#each this.sortedTopHalfEntries as |entry index|}} + + {{/each}} + + + + {{#each this.sortedBottomHalfEntries as |entry index|}} + + {{/each}} + +
+ Rank + + User + + Stages Completed + + Score +
+
+ {{else}} +
+
+ No entries found for this leaderboard. +
+
+ {{/if}} +
\ No newline at end of file diff --git a/app/components/leaderboard-page/entries-table.ts b/app/components/leaderboard-page/entries-table.ts new file mode 100644 index 0000000000..e839eb45b4 --- /dev/null +++ b/app/components/leaderboard-page/entries-table.ts @@ -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 { + @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; + } +} diff --git a/app/components/leaderboard-page/entries-table/filler-row.hbs b/app/components/leaderboard-page/entries-table/filler-row.hbs new file mode 100644 index 0000000000..f0b419fa48 --- /dev/null +++ b/app/components/leaderboard-page/entries-table/filler-row.hbs @@ -0,0 +1,7 @@ + + +
+ ... other users ... +
+ + \ No newline at end of file diff --git a/app/components/leaderboard-page/entries-table/filler-row.ts b/app/components/leaderboard-page/entries-table/filler-row.ts new file mode 100644 index 0000000000..37ded304ed --- /dev/null +++ b/app/components/leaderboard-page/entries-table/filler-row.ts @@ -0,0 +1,13 @@ +import Component from '@glimmer/component'; + +interface Signature { + Element: HTMLTableRowElement; +} + +export default class LeaderboardPageEntriesTableFillerRow extends Component {} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'LeaderboardPage::EntriesTable::FillerRow': typeof LeaderboardPageEntriesTableFillerRow; + } +} diff --git a/app/components/leaderboard-page/entries-table/row.hbs b/app/components/leaderboard-page/entries-table/row.hbs new file mode 100644 index 0000000000..022d08875d --- /dev/null +++ b/app/components/leaderboard-page/entries-table/row.hbs @@ -0,0 +1,59 @@ + + + #{{add @index 1}} + + +
+
+
+ +
+
+ + {{@entry.user.username}} + +
+
+
+ {{#each @entry.relatedCourses as |course|}} +
+ + +
+ {{/each}} +
+
+ + +
+
+ {{@entry.score}} + stages +
+
+ + +
+ {{#if (eq @entry.score 142)}} + + {{/if}} + + {{#if (eq @entry.score 99)}} + + {{/if}} + +
+ {{@entry.score}} + pts +
+
+ + \ No newline at end of file diff --git a/app/components/leaderboard-page/entries-table/row.ts b/app/components/leaderboard-page/entries-table/row.ts new file mode 100644 index 0000000000..b04f659c80 --- /dev/null +++ b/app/components/leaderboard-page/entries-table/row.ts @@ -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 { + get isCurrentUser(): boolean { + return this.args.entry.user.username === 'ry'; + } +} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'LeaderboardPage::EntriesTable::Row': typeof LeaderboardPageEntriesTableRow; + } +} diff --git a/app/components/leaderboard-page/header.hbs b/app/components/leaderboard-page/header.hbs new file mode 100644 index 0000000000..d3dec608c0 --- /dev/null +++ b/app/components/leaderboard-page/header.hbs @@ -0,0 +1,23 @@ +
+
+

+ {{@selectedLanguage.name}} + Leaderboard +

+
+ Leaderboard for + {{@selectedLanguage.name}} + users +
+
+ + +
\ No newline at end of file diff --git a/app/components/leaderboard-page/header.ts b/app/components/leaderboard-page/header.ts new file mode 100644 index 0000000000..5dce509c4f --- /dev/null +++ b/app/components/leaderboard-page/header.ts @@ -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 { + @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; + } +} diff --git a/app/controllers/leaderboard.ts b/app/controllers/leaderboard.ts new file mode 100644 index 0000000000..9f13177cd6 --- /dev/null +++ b/app/controllers/leaderboard.ts @@ -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); + } +} diff --git a/app/models/leaderboard-entry.ts b/app/models/leaderboard-entry.ts index 748f1b9e32..32e7841f76 100644 --- a/app/models/leaderboard-entry.ts +++ b/app/models/leaderboard-entry.ts @@ -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; @@ -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); + } } diff --git a/app/router.ts b/app/router.ts index 3aabfa8f2b..1fe016c4d9 100644 --- a/app/router.ts +++ b/app/router.ts @@ -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 diff --git a/app/routes/leaderboard.ts b/app/routes/leaderboard.ts new file mode 100644 index 0000000000..f644a16d52 --- /dev/null +++ b/app/routes/leaderboard.ts @@ -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 { + (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 + }; + } +} diff --git a/app/styles/utilities.css b/app/styles/utilities.css index a65da72de5..df4f5467f4 100644 --- a/app/styles/utilities.css +++ b/app/styles/utilities.css @@ -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; +} diff --git a/app/templates/leaderboard.hbs b/app/templates/leaderboard.hbs new file mode 100644 index 0000000000..3fdf2ecaf2 --- /dev/null +++ b/app/templates/leaderboard.hbs @@ -0,0 +1,11 @@ +
+ + + {{! }} + +
+ +
+
\ No newline at end of file diff --git a/mirage/config.js b/mirage/config.js index 603c7dcc7b..34bf04d3eb 100644 --- a/mirage/config.js +++ b/mirage/config.js @@ -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'; @@ -181,6 +182,7 @@ function routes() { institutions(this); languages(this); leaderboardEntries(this); + leaderboards(this); logstreams(this); onboardingSurveys(this); perks(this); diff --git a/mirage/handlers/leaderboards.js b/mirage/handlers/leaderboards.js new file mode 100644 index 0000000000..2e4fd708e4 --- /dev/null +++ b/mirage/handlers/leaderboards.js @@ -0,0 +1,3 @@ +export default function (server) { + server.get('/leaderboards'); +} diff --git a/tests/pages/leaderboard-page.ts b/tests/pages/leaderboard-page.ts new file mode 100644 index 0000000000..da4b1fed64 --- /dev/null +++ b/tests/pages/leaderboard-page.ts @@ -0,0 +1,13 @@ +import { text, visitable } from 'ember-cli-page-object'; +import createPage from 'codecrafters-frontend/tests/support/create-page'; + +export default createPage({ + description: text('[data-test-leaderboard-description]'), + + languageDropdown: { + scope: '[data-test-language-dropdown]', + }, + + title: text('[data-test-leaderboard-title]'), + visit: visitable('/leaderboards/:language_slug'), +});