Skip to content

Commit a4f3bc4

Browse files
committed
feat(header): add leaderboard link for users with feature flag
- Add leaderboard link to header for authenticated users when the 'should-see-leaderboard' feature flag is enabled. - Default leaderboard link routes to the user's preferred language leaderboard. - Update acceptance tests to cover new leaderboard link visibility and navigation. - Refactor tests to set up leaderboard data before each test for consistency.
1 parent 26f30a8 commit a4f3bc4

File tree

15 files changed

+175
-10
lines changed

15 files changed

+175
-10
lines changed

app/components/header/index.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
{{did-update this.handleDidUpdateCurrentRouteName this.router.currentRouteName}}
4545
>
4646
{{#each this.links as |link|}}
47-
<Header::Link @route={{link.route}} @text={{link.text}} @type={{link.type}} class="px-2" />
47+
<Header::Link @route={{link.route}} @text={{link.text}} @type={{link.type}} @routeParams={{link.routeParams}} class="px-2" />
4848
{{/each}}
4949

5050
{{! Floating green bar }}

app/components/header/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import logoImage from '/assets/images/logo/logomark-color.svg';
44
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
55
import type ContainerWidthService from 'codecrafters-frontend/services/container-width';
66
import type FeatureFlagsService from 'codecrafters-frontend/services/feature-flags';
7+
import type PreferredLanguageLeaderboardService from 'codecrafters-frontend/services/preferred-language-leaderboard';
78
import type RouterService from '@ember/routing/router-service';
89
import type VersionTrackerService from 'codecrafters-frontend/services/version-tracker';
910
import type { SafeString } from '@ember/template/-private/handlebars';
@@ -23,6 +24,7 @@ export default class Header extends Component<Signature> {
2324
@service declare authenticator: AuthenticatorService;
2425
@service declare containerWidth: ContainerWidthService;
2526
@service declare featureFlags: FeatureFlagsService;
27+
@service declare preferredLanguageLeaderboard: PreferredLanguageLeaderboardService;
2628
@service declare router: RouterService;
2729
@service declare versionTracker: VersionTrackerService;
2830

@@ -54,12 +56,21 @@ export default class Header extends Component<Signature> {
5456
];
5557
}
5658

57-
get linksForAuthenticatedUser(): { text: string; route: string; type: 'route' | 'link' }[] {
58-
const links: { text: string; route: string; type: 'route' | 'link' }[] = [
59+
get linksForAuthenticatedUser(): { text: string; route: string; type: 'route' | 'link'; routeParams?: string[] }[] {
60+
const links: { text: string; route: string; type: 'route' | 'link'; routeParams?: string[] }[] = [
5961
{ text: 'Catalog', route: 'catalog', type: 'route' },
6062
{ text: 'Roadmap', route: 'roadmap', type: 'route' },
6163
];
6264

65+
if (this.featureFlags.shouldSeeLeaderboard) {
66+
links.push({
67+
text: 'Leaderboard',
68+
route: 'leaderboard',
69+
type: 'route',
70+
routeParams: [this.preferredLanguageLeaderboard.defaultLanguageSlug],
71+
});
72+
}
73+
6374
return links;
6475
}
6576

app/components/header/link.hbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{{#if (eq @type "route")}}
22
<LinkTo
33
@route={{@route}}
4+
@models={{(or @routeParams (array))}}
45
class="text-sm relative hover:text-gray-900 dark:hover:text-gray-100
56
{{if this.isActive 'text-gray-700 dark:text-gray-300' 'text-gray-600 dark:text-gray-400'}}
67
"

app/components/header/link.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface Signature {
99
text: string;
1010
type: 'link' | 'route';
1111
route: string;
12+
routeParams?: string[];
1213
};
1314
}
1415

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import ApplicationInstance from '@ember/application/instance';
2+
import type PreferredLanguageLeaderboardService from 'codecrafters-frontend/services/preferred-language-leaderboard';
3+
4+
export function initialize(applicationInstance: ApplicationInstance) {
5+
const service = applicationInstance.lookup('service:preferred-language-leaderboard') as PreferredLanguageLeaderboardService;
6+
service.onBoot();
7+
}
8+
9+
export default {
10+
initialize,
11+
};

app/models/leaderboard-entry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Model, { attr, belongsTo } from '@ember-data/model';
22
import type LeaderboardModel from './leaderboard';
33
import type UserModel from './user';
44
import CourseModel from './course';
5+
import { collectionAction } from 'ember-api-actions';
56

67
export default class LeaderboardEntryModel extends Model {
78
@belongsTo('leaderboard', { async: false, inverse: 'entries' }) declare leaderboard: LeaderboardModel;
@@ -22,4 +23,22 @@ export default class LeaderboardEntryModel extends Model {
2223

2324
return this.relatedCourseSlugs.map((slug) => allCourses.find((course) => course.slug === slug)).filter(Boolean);
2425
}
26+
27+
declare fetchForCurrentUser: (this: Model, payload: unknown) => Promise<LeaderboardEntryModel[]>;
2528
}
29+
30+
LeaderboardEntryModel.prototype.fetchForCurrentUser = collectionAction({
31+
path: 'for-current-user',
32+
type: 'get',
33+
urlType: 'findAll',
34+
35+
after(response) {
36+
if (response.data) {
37+
this.store.pushPayload(response);
38+
39+
return response.data.map((data: { id: string }) => this.store.peekRecord('leaderboard-entry', data.id));
40+
} else {
41+
return [];
42+
}
43+
},
44+
});

app/models/leaderboard.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
22
import type ContestModel from './contest';
33
import type LeaderboardEntryModel from './leaderboard-entry';
4+
import type LanguageModel from './language';
45

56
export default class LeaderboardModel extends Model {
67
@belongsTo('contest', { async: false, inverse: 'leaderboard' }) declare contest: ContestModel;
8+
@belongsTo('language', { async: false, inverse: 'leaderboard' }) declare language: LanguageModel;
9+
710
@hasMany('leaderboard-entry', { async: false, inverse: 'leaderboard' }) declare entries: LeaderboardEntryModel[];
811

912
@attr('string') declare type: string;

app/services/feature-flags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export default class FeatureFlagsService extends Service {
1818
return this.authenticator.currentUser;
1919
}
2020

21+
get shouldSeeLeaderboard(): boolean {
22+
return this.getFeatureFlagValue('should-see-leaderboard') === 'true';
23+
}
24+
2125
getFeatureFlagValue(flagName: string): string | null | undefined {
2226
const value = this.currentUser?.featureFlags?.[flagName];
2327

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Service, { inject as service } from '@ember/service';
2+
import LocalStorageService from 'codecrafters-frontend/services/local-storage';
3+
import { tracked } from '@glimmer/tracking';
4+
import { action } from '@ember/object';
5+
import type AuthenticatorService from './authenticator';
6+
import LeaderboardEntryModel from 'codecrafters-frontend/models/leaderboard-entry';
7+
import type Store from '@ember-data/store';
8+
9+
class StoredData {
10+
languageSlugs: string[];
11+
storedAt: Date;
12+
13+
constructor(languageSlugs: string[], storedAt?: Date) {
14+
this.languageSlugs = languageSlugs;
15+
this.storedAt = storedAt || new Date();
16+
}
17+
18+
static fromJSON(json: string): StoredData {
19+
const { languageSlugs, storedAt } = JSON.parse(json);
20+
21+
return new StoredData(languageSlugs, storedAt);
22+
}
23+
24+
toJSON(): string {
25+
return JSON.stringify({
26+
languageSlugs: this.languageSlugs,
27+
storedAt: this.storedAt,
28+
});
29+
}
30+
}
31+
32+
export default class PreferredLanguageLeaderboardService extends Service {
33+
static STORAGE_KEY = 'preferred-language-leaderboard-v1';
34+
35+
@service declare authenticator: AuthenticatorService;
36+
@service declare localStorage: LocalStorageService;
37+
@service declare store: Store;
38+
39+
// We default to Rust since it's the first track in the catalog
40+
@tracked preferredLanguageSlugs: string[] = [];
41+
42+
// This is used when a user clicks on the Leaderboard link in the header
43+
get defaultLanguageSlug(): string {
44+
return this.preferredLanguageSlugs[0] || 'rust';
45+
}
46+
47+
@action
48+
async onBoot(): Promise<void> {
49+
if (!this.authenticator.isAuthenticated) {
50+
return;
51+
}
52+
53+
const serializedStoredData = this.localStorage.getItem(PreferredLanguageLeaderboardService.STORAGE_KEY);
54+
55+
if (!serializedStoredData) {
56+
return;
57+
}
58+
59+
// Let's use the latest value we have from
60+
const storedData = StoredData.fromJSON(serializedStoredData);
61+
this.preferredLanguageSlugs = storedData.languageSlugs;
62+
63+
// Re-fetch if data is more than 6 hours old
64+
if (storedData.storedAt.getTime() < Date.now() - 1000 * 60 * 60 * 6) {
65+
await this.refresh();
66+
}
67+
}
68+
69+
async refresh(): Promise<void> {
70+
await this.authenticator.authenticate();
71+
72+
const userLeaderboardEntries = (await this.store
73+
.createRecord('leaderboard-entry')
74+
.fetchForCurrentUser({ include: 'leaderboard,leaderboard.language,user' })) as unknown as LeaderboardEntryModel[];
75+
76+
this.preferredLanguageSlugs = userLeaderboardEntries
77+
.filter((entry) => entry.score > 0)
78+
.sort((a, b) => b.score - a.score)
79+
.map((entry) => entry.leaderboard.language.slug)
80+
.slice(0, 3);
81+
82+
this.localStorage.setItem(PreferredLanguageLeaderboardService.STORAGE_KEY, new StoredData(this.preferredLanguageSlugs, new Date()).toJSON());
83+
}
84+
}

mirage/handlers/leaderboard-entries.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import CurrentMirageUser from 'codecrafters-frontend/mirage/utils/current-mirage-user';
2+
13
export default function (server) {
24
server.get('/leaderboard-entries', function (schema, request) {
35
if (!request.queryParams.leaderboard_id) {
@@ -30,4 +32,8 @@ export default function (server) {
3032
throw new Error(`Invalid filter type: ${request.queryParams.filter_type}`);
3133
}
3234
});
35+
36+
server.get('/leaderboard-entries/for-current-user', function (schema) {
37+
return schema.leaderboardEntries.all().filter((leaderboardEntry) => leaderboardEntry.user.id === CurrentMirageUser.currentUserId);
38+
});
3339
}

0 commit comments

Comments
 (0)