Skip to content

Commit e56cbef

Browse files
authored
Merge pull request #3087 from codecrafters-io/feat-add-leaderboard-link-header
feat(header): add leaderboard link for users with feature flag
2 parents 26f30a8 + 47a0345 commit e56cbef

File tree

18 files changed

+241
-33
lines changed

18 files changed

+241
-33
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: 21 additions & 3 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,13 +24,21 @@ 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

2931
@tracked mobileMenuIsExpanded = false;
3032
@tracked floatingBarStyle: SafeString = htmlSafe('left: 0px; width: 0px; opacity: 0;');
3133
@tracked floatingBarContainer: HTMLElement | null = null;
3234

35+
constructor(owner: unknown, args: object) {
36+
super(owner, args);
37+
38+
// This can't be in instance-initializers since it depends on the authenticator service
39+
this.preferredLanguageLeaderboard.onBoot();
40+
}
41+
3342
get activeDiscountForYearlyPlan(): PromotionalDiscountModel | null {
3443
return this.currentUser?.activeDiscountForYearlyPlan || null;
3544
}
@@ -38,7 +47,7 @@ export default class Header extends Component<Signature> {
3847
return this.authenticator.currentUser;
3948
}
4049

41-
get links(): { text: string; route: string; type: 'route' | 'link' }[] {
50+
get links(): { text: string; route: string; type: 'route' | 'link'; routeParams?: string[] }[] {
4251
if (this.currentUser) {
4352
return this.linksForAuthenticatedUser;
4453
} else {
@@ -54,12 +63,21 @@ export default class Header extends Component<Signature> {
5463
];
5564
}
5665

57-
get linksForAuthenticatedUser(): { text: string; route: string; type: 'route' | 'link' }[] {
58-
const links: { text: string; route: string; type: 'route' | 'link' }[] = [
66+
get linksForAuthenticatedUser(): { text: string; route: string; type: 'route' | 'link'; routeParams?: string[] }[] {
67+
const links: { text: string; route: string; type: 'route' | 'link'; routeParams?: string[] }[] = [
5968
{ text: 'Catalog', route: 'catalog', type: 'route' },
6069
{ text: 'Roadmap', route: 'roadmap', type: 'route' },
6170
];
6271

72+
if (this.featureFlags.shouldSeeLeaderboard) {
73+
links.push({
74+
text: 'Leaderboard',
75+
route: 'leaderboard',
76+
type: 'route',
77+
routeParams: [this.preferredLanguageLeaderboard.defaultLanguageSlug],
78+
});
79+
}
80+
6381
return links;
6482
}
6583

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

app/components/leaderboard-page/header.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { inject as service } from '@ember/service';
44
import type LanguageModel from 'codecrafters-frontend/models/language';
55
import type RouterService from '@ember/routing/router-service';
66
import type Store from '@ember-data/store';
7+
import type PreferredLanguageLeaderboardService from 'codecrafters-frontend/services/preferred-language-leaderboard';
78

89
interface Signature {
910
Element: HTMLDivElement;
@@ -14,14 +15,24 @@ interface Signature {
1415
}
1516

1617
export default class LeaderboardPageHeader extends Component<Signature> {
18+
@service declare preferredLanguageLeaderboard: PreferredLanguageLeaderboardService;
1719
@service declare router: RouterService;
1820
@service declare store: Store;
1921

2022
get sortedLanguagesForDropdown(): LanguageModel[] {
21-
return this.store
22-
.peekAll('language')
23-
.sortBy('sortPositionForTrack')
24-
.filter((language) => language.liveOrBetaStagesCount > 0);
23+
const allLanguages = this.store.peekAll('language');
24+
const preferredLanguageSlugs = this.preferredLanguageLeaderboard.preferredLanguageSlugs;
25+
26+
return [
27+
// First show the user's preferred languages
28+
...preferredLanguageSlugs.map((slug) => allLanguages.find((language) => language.slug === slug)).filter(Boolean),
29+
30+
// Next, show all languages alphabetically
31+
...allLanguages
32+
.sortBy('sortPositionForTrack')
33+
.reject((language) => preferredLanguageSlugs.includes(language.slug))
34+
.filter((language) => language.liveOrBetaStagesCount > 0),
35+
];
2536
}
2637

2738
@action

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 | null;
9+
710
@hasMany('leaderboard-entry', { async: false, inverse: 'leaderboard' }) declare entries: LeaderboardEntryModel[];
811

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

app/models/user.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,41 +226,47 @@ export default class UserModel extends Model {
226226

227227
declare fetchCurrent: (this: Model, payload: unknown) => Promise<UserModel | null>;
228228
declare fetchNextInvoicePreview: (this: Model, payload: unknown) => Promise<InvoiceModel | null>;
229+
declare fetchTopLanguageLeaderboardSlugs: (this: Model, payload: unknown) => Promise<string[]>;
229230
declare syncFeatureFlags: (this: Model, payload: unknown) => Promise<void>;
230231
declare syncUsernameFromGitHub: (this: Model, payload: unknown) => Promise<void>;
231232
}
232233

233-
UserModel.prototype.fetchNextInvoicePreview = memberAction({
234-
path: 'next-invoice-preview',
234+
UserModel.prototype.fetchCurrent = collectionAction({
235+
path: 'current',
235236
type: 'get',
237+
urlType: 'findRecord',
236238

237239
after(response) {
238240
if (response.data) {
239241
this.store.pushPayload(response);
240242

241-
return this.store.peekRecord('invoice', response.data.id);
243+
return this.store.peekRecord('user', response.data.id);
242244
} else {
243245
return null;
244246
}
245247
},
246248
});
247249

248-
UserModel.prototype.fetchCurrent = collectionAction({
249-
path: 'current',
250+
UserModel.prototype.fetchNextInvoicePreview = memberAction({
251+
path: 'next-invoice-preview',
250252
type: 'get',
251-
urlType: 'findRecord',
252253

253254
after(response) {
254255
if (response.data) {
255256
this.store.pushPayload(response);
256257

257-
return this.store.peekRecord('user', response.data.id);
258+
return this.store.peekRecord('invoice', response.data.id);
258259
} else {
259260
return null;
260261
}
261262
},
262263
});
263264

265+
UserModel.prototype.fetchTopLanguageLeaderboardSlugs = memberAction({
266+
path: 'top-language-leaderboard-slugs',
267+
type: 'get',
268+
});
269+
264270
UserModel.prototype.syncFeatureFlags = memberAction({
265271
path: 'sync-feature-flags',
266272
type: 'post',

app/routes/contest.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,15 @@ export default class ContestRoute extends BaseRoute {
111111
}
112112
}
113113

114-
const topLeaderboardEntries = await this.store.findAll('leaderboard-entry', {
114+
// TODO[Vasyl]: This has an issue where it can end up picking _any_ leaderboard entry and not just the top ones. We
115+
// don't happen to render contest leaderboards elsewhere so it isn't a problem for now. As a pattern this is something
116+
// I'd like to figure out though. Maybe we use `query` instead of the adapterOptions strategy? Or maybe more explicit caching?
117+
const topLeaderboardEntries = (await this.store.findAll('leaderboard-entry', {
115118
adapterOptions: {
116119
leaderboard_id: contest.leaderboard.id,
117120
},
118121
include: 'user,leaderboard',
119-
});
122+
})) as unknown as LeaderboardEntryModel[];
120123

121124
const languages = await this.store.findAll('language');
122125

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

0 commit comments

Comments
 (0)