Skip to content

Commit 1aa0534

Browse files
authored
Merge pull request #2825 from codecrafters-io/feature/add-stage-list-item-components
Add Code example insights index page
2 parents 59128d0 + 20a50c5 commit 1aa0534

File tree

17 files changed

+488
-3
lines changed

17 files changed

+488
-3
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,7 @@
2929

3030
# broccoli-debug
3131
/DEBUG/
32+
3233
.aider*
34+
.claude/
35+
.machtiani.ignore
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<LinkTo
2+
class="flex items-center justify-between flex-wrap gap-x-6 gap-y-2 py-3 group/stage-list-item hover:bg-gray-50 px-1"
3+
data-test-code-example-insights-index-list-item
4+
role="button"
5+
@route="course.stage.code-examples"
6+
@models={{array @stage.course.slug @stage.slug}}
7+
target="_blank"
8+
...attributes
9+
>
10+
<div class="min-w-0">
11+
<div class="flex items-center flex-wrap gap-2">
12+
<div class="font-semibold text-sm text-gray-700">{{@stage.name}}</div>
13+
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current text-gray-300">
14+
<circle cx="1" cy="1" r="1" />
15+
</svg>
16+
<CourseAdmin::CodeExampleInsightsIndexPage::StageListItem::Statistic
17+
@statistic={{this.analysis.solutionsCountStatistic}}
18+
@fallbackLabel="solutions"
19+
/>
20+
</div>
21+
</div>
22+
<div class="flex flex-none items-center gap-x-2">
23+
<CourseAdmin::CodeExampleInsightsIndexPage::StageListItem::Statistic
24+
@statistic={{this.analysis.medianChangedLinesStatistic}}
25+
@fallbackLabel="lines"
26+
/>
27+
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current text-gray-300">
28+
<circle cx="1" cy="1" r="1" />
29+
</svg>
30+
<CourseAdmin::CodeExampleInsightsIndexPage::StageListItem::Statistic
31+
@statistic={{this.analysis.upvotesCountStatistic}}
32+
@fallbackLabel="upvotes"
33+
/>
34+
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current text-gray-300">
35+
<circle cx="1" cy="1" r="1" />
36+
</svg>
37+
<CourseAdmin::CodeExampleInsightsIndexPage::StageListItem::Statistic
38+
@statistic={{this.analysis.downvotesCountStatistic}}
39+
@fallbackLabel="downvotes"
40+
/>
41+
</div>
42+
</LinkTo>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Component from '@glimmer/component';
2+
import type CourseStageModel from 'codecrafters-frontend/models/course-stage';
3+
import type LanguageModel from 'codecrafters-frontend/models/language';
4+
5+
interface Signature {
6+
Element: HTMLAnchorElement;
7+
8+
Args: {
9+
language: LanguageModel;
10+
stage: CourseStageModel;
11+
};
12+
}
13+
14+
export default class StageListItemComponent extends Component<Signature> {
15+
get analysis() {
16+
return this.args.stage.communitySolutionsAnalyses.findBy('language', this.args.language);
17+
}
18+
}
19+
20+
declare module '@glint/environment-ember-loose/registry' {
21+
export default interface Registry {
22+
'CourseAdmin::CodeExampleInsightsIndexPage::StageListItem': typeof StageListItemComponent;
23+
}
24+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<div class="inline-flex items-center gap-x-1 text-xs">
2+
<div class="{{this.valueColorClasses}}">
3+
{{#if (and @statistic @statistic.value)}}
4+
<span class="font-semibold">
5+
{{@statistic.value}}
6+
</span>
7+
{{else}}
8+
-
9+
{{/if}}
10+
</div>
11+
<div class="text-gray-400">
12+
{{#if @statistic}}
13+
{{@statistic.label}}
14+
{{else if @fallbackLabel}}
15+
{{@fallbackLabel}}
16+
{{/if}}
17+
</div>
18+
19+
{{#if @statistic}}
20+
<EmberTooltip>
21+
<div class="prose prose-sm prose-invert text-white">
22+
{{markdown-to-html @statistic.explanationMarkdown}}
23+
</div>
24+
</EmberTooltip>
25+
{{/if}}
26+
</div>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Component from '@glimmer/component';
2+
import type { CommunitySolutionsAnalysisStatistic } from 'codecrafters-frontend/models/community-solutions-analysis';
3+
4+
interface Signature {
5+
Element: HTMLDivElement;
6+
7+
Args: {
8+
fallbackLabel?: string;
9+
statistic?: CommunitySolutionsAnalysisStatistic;
10+
};
11+
}
12+
13+
export default class StatisticComponent extends Component<Signature> {
14+
get valueColorClasses(): string {
15+
if (!this.args.statistic) {
16+
return 'text-gray-500';
17+
}
18+
19+
return {
20+
green: 'text-teal-500',
21+
yellow: 'text-yellow-500',
22+
red: 'text-red-500',
23+
gray: 'text-gray-500',
24+
}[this.args.statistic.color];
25+
}
26+
}
27+
28+
declare module '@glint/environment-ember-loose/registry' {
29+
export default interface Registry {
30+
'CourseAdmin::CodeExampleInsightsIndexPage::StageListItem::Statistic': typeof StatisticComponent;
31+
}
32+
}

app/components/course-admin/header.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export default class CourseAdminHeaderComponent extends Component<Signature> {
8181
models: [this.args.course.slug],
8282
isActive: ['course-admin.stage-insights-index', 'course-admin.stage-insights'].includes(this.router.currentRouteName),
8383
},
84+
{
85+
icon: 'document-text',
86+
name: 'Code Examples',
87+
slug: 'code-examples',
88+
route: 'course-admin.code-example-insights-index',
89+
models: [this.args.course.slug],
90+
isActive: ['course-admin.code-example-insights-index', 'course-admin.code-example-insights'].includes(this.router.currentRouteName),
91+
},
8492
{
8593
icon: 'academic-cap',
8694
name: 'Evaluators',
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Controller from '@ember/controller';
2+
import { action } from '@ember/object';
3+
import { inject as service } from '@ember/service';
4+
import type RouterService from '@ember/routing/router-service';
5+
import type LanguageModel from 'codecrafters-frontend/models/language';
6+
import Store from '@ember-data/store';
7+
import type { ModelType } from 'codecrafters-frontend/routes/course-admin/code-example-insights-index';
8+
9+
export default class CodeExampleInsightsIndexController extends Controller {
10+
@service declare router: RouterService;
11+
@service declare store: Store;
12+
13+
declare model: ModelType;
14+
15+
@action
16+
onLanguageChange(language: LanguageModel) {
17+
this.router.transitionTo({
18+
queryParams: { language_slug: language.slug },
19+
});
20+
}
21+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import Model, { attr, belongsTo } from '@ember-data/model';
2+
import type CourseStageModel from './course-stage';
3+
import type LanguageModel from './language';
4+
5+
export type CommunitySolutionsAnalysisStatistic = {
6+
color: 'green' | 'yellow' | 'red' | 'gray';
7+
explanationMarkdown: string;
8+
label: string;
9+
title: string;
10+
value: string | null;
11+
};
12+
13+
const solutionsCountThresholds = {
14+
green: 100,
15+
yellow: 15,
16+
};
17+
18+
const upvotesCountThresholds = {
19+
green: 5,
20+
yellow: 1,
21+
};
22+
23+
const downvotesCountThresholds = {
24+
red: 3,
25+
yellow: 3,
26+
};
27+
28+
const downvotesCountExplanationMarkdown = `
29+
The number of downvotes on "scored" community solutions for this stage and language.
30+
31+
Solutions are "scored" when they are recommended by our system. Downvotes indicate that users did not find these solutions helpful.
32+
`.trim();
33+
34+
const medianChangedLinesExplanationMarkdown = `
35+
The median number of changed lines across all solutions for this stage and language.
36+
37+
A high number may indicate that the stage requires extensive changes, while a low number typically indicates a more focused stage.
38+
`.trim();
39+
40+
const solutionsCountExplanationMarkdown = `
41+
The number of community solutions available for this stage and language.
42+
43+
A higher number indicates more examples for users to learn from.
44+
`.trim();
45+
46+
const upvotesCountExplanationMarkdown = `
47+
The number of upvotes on "scored" community solutions for this stage and language.
48+
49+
Solutions are "scored" when they are recommended by our system. Upvotes indicate that users found these solutions helpful.
50+
`.trim();
51+
52+
export default class CommunitySolutionsAnalysisModel extends Model {
53+
@belongsTo('course-stage', { async: false, inverse: 'communitySolutionsAnalyses' }) declare courseStage: CourseStageModel;
54+
@belongsTo('language', { async: false, inverse: null }) declare language: LanguageModel;
55+
56+
@attr() declare changedLinesCountDistribution: Record<string, number>;
57+
@attr('number') declare scoredSolutionDownvotesCount: number;
58+
@attr('number') declare scoredSolutionUpvotesCount: number;
59+
@attr('number') declare solutionsCount: number;
60+
61+
get downvotesCountStatistic(): CommunitySolutionsAnalysisStatistic {
62+
return {
63+
title: 'Downvotes on Scored Solutions',
64+
label: 'downvotes',
65+
value: this.scoredSolutionDownvotesCount !== undefined ? this.scoredSolutionDownvotesCount.toString() : null,
66+
color:
67+
this.scoredSolutionDownvotesCount !== undefined
68+
? this.calculateColorUsingInverseThresholds(this.scoredSolutionDownvotesCount, downvotesCountThresholds)
69+
: 'gray',
70+
explanationMarkdown: downvotesCountExplanationMarkdown,
71+
};
72+
}
73+
74+
get medianChangedLinesStatistic(): CommunitySolutionsAnalysisStatistic {
75+
return {
76+
title: 'Median Changed Lines',
77+
label: 'lines',
78+
value: this.p50 !== undefined ? this.p50.toString() : null,
79+
color: 'gray',
80+
explanationMarkdown: medianChangedLinesExplanationMarkdown,
81+
};
82+
}
83+
84+
get p50(): number {
85+
return this.changedLinesCountDistribution?.['p50'] || 0;
86+
}
87+
88+
get solutionsCountStatistic(): CommunitySolutionsAnalysisStatistic {
89+
return {
90+
title: 'Solutions Count',
91+
label: 'solutions',
92+
value: this.solutionsCount !== undefined ? this.solutionsCount.toString() : null,
93+
color: this.solutionsCount !== undefined ? this.calculateColorUsingThresholds(this.solutionsCount, solutionsCountThresholds) : 'gray',
94+
explanationMarkdown: solutionsCountExplanationMarkdown,
95+
};
96+
}
97+
98+
get upvotesCountStatistic(): CommunitySolutionsAnalysisStatistic {
99+
return {
100+
title: 'Upvotes on Scored Solutions',
101+
label: 'upvotes',
102+
value: this.scoredSolutionUpvotesCount !== undefined ? this.scoredSolutionUpvotesCount.toString() : null,
103+
color:
104+
this.scoredSolutionUpvotesCount !== undefined
105+
? this.calculateColorUsingThresholds(this.scoredSolutionUpvotesCount, upvotesCountThresholds)
106+
: 'gray',
107+
explanationMarkdown: upvotesCountExplanationMarkdown,
108+
};
109+
}
110+
111+
private calculateColorUsingInverseThresholds(value: number, thresholds: { red: number; yellow: number }): 'red' | 'yellow' | 'green' {
112+
if (value >= thresholds.red) {
113+
return 'red';
114+
} else if (value >= thresholds.yellow) {
115+
return 'yellow';
116+
} else {
117+
return 'green';
118+
}
119+
}
120+
121+
private calculateColorUsingThresholds(value: number, thresholds: { green: number; yellow: number }): 'green' | 'yellow' | 'red' {
122+
if (value >= thresholds.green) {
123+
return 'green';
124+
} else if (value >= thresholds.yellow) {
125+
return 'yellow';
126+
} else {
127+
return 'red';
128+
}
129+
}
130+
}

app/models/course-stage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import type RepositoryModel from './repository';
1111
import Mustache from 'mustache';
1212
import type CourseStageParticipationAnalysisModel from './course-stage-participation-analysis';
1313
import type CourseStageParticipationModel from './course-stage-participation';
14+
import type CommunitySolutionsAnalysisModel from './community-solutions-analysis';
1415

1516
export default class CourseStageModel extends Model {
1617
@belongsTo('course', { async: false, inverse: 'stages' }) declare course: CourseModel;
1718

19+
@hasMany('community-solutions-analysis', { async: false, inverse: 'courseStage' })
20+
declare communitySolutionsAnalyses: CommunitySolutionsAnalysisModel[];
21+
1822
@hasMany('course-stage-comment', { async: false, inverse: 'target' }) declare comments: CourseStageCommentModel[];
1923
@hasMany('community-course-stage-solution', { async: false, inverse: 'courseStage' })
2024
declare communitySolutions: CommunityCourseStageSolutionModel[];

app/router.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ Router.map(function () {
2727
this.route('courses');
2828

2929
this.route('course-admin', { path: '/courses/:course_slug/admin' }, function () {
30-
this.route('buildpacks');
3130
this.route('buildpack', { path: '/buildpacks/:buildpack_id' });
31+
this.route('buildpacks');
3232
this.route('code-example', { path: '/code-examples/:code_example_id' });
33-
this.route('code-example-evaluators');
3433
this.route('code-example-evaluator', { path: '/code-example-evaluators/:evaluator_slug' });
34+
this.route('code-example-evaluators');
35+
this.route('code-example-insights', { path: '/code-examples/stage/:stage_slug' });
36+
this.route('code-example-insights-index', { path: '/code-examples' });
3537
this.route('feedback');
3638
this.route('insights');
37-
this.route('stage-insights-index', { path: '/stage-insights' });
3839
this.route('stage-insights', { path: '/stage-insights/:stage_slug' });
40+
this.route('stage-insights-index', { path: '/stage-insights' });
3941
this.route('submissions');
4042
this.route('tester-version', { path: '/tester-versions/:tester_version_id' });
4143
this.route('tester-versions');

0 commit comments

Comments
 (0)