Skip to content

Commit 7036d99

Browse files
authored
Merge pull request #3124 from codecrafters-io/arpan/cc-1937-replace-course-track-progress-bars-with-donuts-animation
Refactor progress bar to use SVG donut for visual representation of p…
2 parents eb845b2 + 901086b commit 7036d99

File tree

10 files changed

+102
-60
lines changed

10 files changed

+102
-60
lines changed

app/components/course-card/progress-bar.hbs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,6 @@
1-
<div class="flex items-center" data-test-course-progress ...attributes>
2-
<div class="rounded-sm overflow-hidden mr-2">
3-
<div
4-
class="{{if @lastUsedRepository.allStagesAreComplete 'bg-teal-100 dark:bg-teal-900' 'bg-blue-100 dark:bg-blue-900'}}
5-
h-3 w-16 flex items-stretch"
6-
>
7-
{{#if (eq @lastUsedRepository.completedStages.length 0)}}
8-
<div class="bg-blue-500 w-1/12"></div>
9-
{{else}}
10-
<div
11-
data-test-course-progress-bar
12-
class={{if @lastUsedRepository.allStagesAreComplete "bg-teal-500" "bg-blue-500"}}
13-
style={{html-safe (concat "width:" (round (mult 100 (div @lastUsedRepository.completedStages.length @course.stages.length))) "%")}}
14-
></div>
15-
{{/if}}
16-
</div>
1+
<div class="flex items-center gap-1.5" data-test-course-progress ...attributes>
2+
<div class="w-5 h-5">
3+
<ProgressDonut @total={{@course.stages.length}} @completed={{or @lastUsedRepository.completedStages.length 0}} data-test-course-progress-donut />
174
</div>
185

196
<div class="text-xs {{if @lastUsedRepository.allStagesAreComplete 'text-teal-500' 'text-blue-500'}} mr-3" data-test-course-progress-text>

app/components/progress-donut.hbs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<svg viewBox="0 0 24 24" class="w-full h-full transform -rotate-90">
2+
{{! Base circle (background) }}
3+
<circle
4+
cx="12"
5+
cy="12"
6+
r="9"
7+
fill="none"
8+
stroke-width="5"
9+
class="{{if this.isComplete 'stroke-teal-100 dark:stroke-teal-900' 'stroke-blue-100 dark:stroke-blue-900'}}"
10+
/>
11+
{{! Progress circle }}
12+
<circle
13+
cx="12"
14+
cy="12"
15+
r="9"
16+
fill="none"
17+
stroke-width="5"
18+
class="progress-donut-circle {{if this.isComplete 'stroke-teal-500' 'stroke-blue-500'}}"
19+
style={{this.progressStyle}}
20+
...attributes
21+
/>
22+
</svg>

app/components/progress-donut.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Component from '@glimmer/component';
2+
import { htmlSafe } from '@ember/template';
3+
import type { SafeString } from '@ember/template/-private/handlebars';
4+
5+
interface Signature {
6+
Element: SVGElement;
7+
8+
Args: {
9+
total: number;
10+
completed: number;
11+
};
12+
}
13+
14+
export default class ProgressDonut extends Component<Signature> {
15+
get isComplete(): boolean {
16+
return this.args.total > 0 && this.args.completed >= this.args.total;
17+
}
18+
19+
get progressPercentage(): number {
20+
if (this.args.total === 0) {
21+
return 0;
22+
}
23+
24+
// Apply minimum width of 10%
25+
return Math.max(10, (100 * this.args.completed) / this.args.total);
26+
}
27+
28+
get progressStyle(): SafeString {
29+
const targetOffset = (2 * Math.PI * 9 * (100 - this.progressPercentage)) / 100;
30+
31+
return htmlSafe(`--target-offset: ${targetOffset}`);
32+
}
33+
}
34+
35+
declare module '@glint/environment-ember-loose/registry' {
36+
export default interface Registry {
37+
ProgressDonut: typeof ProgressDonut;
38+
}
39+
}

app/components/track-page/course-card/progress-bar.hbs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
<div class="flex items-center" data-test-course-progress ...attributes>
2-
<div class="rounded-sm overflow-hidden mr-2">
3-
<div
4-
class="{{if @lastPushedRepository.allStagesAreComplete 'bg-teal-100 dark:bg-teal-900' 'bg-blue-100 dark:bg-blue-900'}}
5-
h-4 w-20 flex items-stretch"
6-
>
7-
{{#if (eq @lastPushedRepository.completedStages.length 0)}}
8-
<div class="bg-blue-500 w-1/12"></div>
9-
{{else}}
10-
<div
11-
data-test-course-progress-bar
12-
class={{if @lastPushedRepository.allStagesAreComplete "bg-teal-500" "bg-blue-500"}}
13-
style={{html-safe (concat "width:" (round (mult 100 (div @lastPushedRepository.completedStages.length @course.stages.length))) "%")}}
14-
></div>
15-
{{/if}}
16-
</div>
1+
<div class="flex items-center gap-1.5" data-test-course-progress ...attributes>
2+
<div class="w-6 h-6">
3+
<ProgressDonut
4+
@total={{@course.stages.length}}
5+
@completed={{or @lastPushedRepository.completedStages.length 0}}
6+
data-test-course-progress-donut
7+
/>
178
</div>
189

19-
<div class="{{if @lastPushedRepository.allStagesAreComplete 'text-teal-500' 'text-blue-500'}} mr-3" data-test-course-progress-text>
10+
<div class="text-sm {{if @lastPushedRepository.allStagesAreComplete 'text-teal-500' 'text-blue-500'}} mr-3" data-test-course-progress-text>
2011
<b class="font-bold">{{@lastPushedRepository.completedStages.length}}/{{@course.stages.length}}</b>
2112
stages
2213
</div>

app/components/tracks-page/track-card/progress-bar.hbs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
1-
<div class="flex items-center" data-test-track-progress>
2-
<div class="rounded-sm overflow-hidden mr-2">
3-
<div
4-
class="{{if (eq @stagesCount @completedStagesCount) 'bg-teal-100 dark:bg-teal-900' 'bg-blue-100 dark:bg-blue-900'}} h-3 w-16 flex items-stretch"
5-
>
6-
<div
7-
data-test-track-progress-bar
8-
class={{if (eq @stagesCount @completedStagesCount) "bg-teal-500" "bg-blue-500"}}
9-
style={{html-safe (concat "width:" (round (mult 100 (div (or @completedStagesCount 1) @stagesCount))) "%")}}
10-
></div>
11-
</div>
1+
<div class="flex items-center gap-1.5" data-test-track-progress>
2+
<div class="w-5 h-5">
3+
<ProgressDonut @total={{@stagesCount}} @completed={{@completedStagesCount}} data-test-track-progress-donut />
124
</div>
135

146
<div class="text-xs {{if (eq @stagesCount @completedStagesCount) 'text-teal-500' 'text-blue-500'}} mr-3" data-test-track-progress-text>

app/styles/app.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ body {
4040
background: linear-gradient(to left, rgb(241 245 249 / 100%) 0%, rgb(241 245 249 / 100%) 20%, rgb(241 245 249 / 0%) 100%);
4141
}
4242

43+
@keyframes progress-donut {
44+
from {
45+
stroke-dashoffset: 56.55;
46+
}
47+
48+
to {
49+
stroke-dashoffset: var(--target-offset);
50+
}
51+
}
52+
53+
.progress-donut-circle {
54+
stroke-dasharray: 56.55 56.55;
55+
stroke-dashoffset: 56.55;
56+
animation: progress-donut 400ms ease-out forwards;
57+
}
58+
4359
.top-50-percent {
4460
top: 50%;
4561
}

tests/acceptance/view-courses-test.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,9 @@ module('Acceptance | view-courses', function (hooks) {
9393
assert.strictEqual(catalogPage.courseCards[2].actionText, 'Start');
9494
assert.strictEqual(catalogPage.courseCards[3].actionText, 'Start');
9595

96-
assert.true(catalogPage.courseCards[0].hasProgressBar);
96+
assert.true(catalogPage.courseCards[0].hasProgressDonut);
9797
assert.false(catalogPage.courseCards[0].hasDifficultyLabel);
9898
assert.strictEqual(catalogPage.courseCards[0].progressText, '1/55 stages');
99-
assert.strictEqual(catalogPage.courseCards[0].progressBarStyle, 'width:2%');
10099
});
101100

102101
test('it renders with progress if user has created a repository', async function (assert) {
@@ -116,7 +115,7 @@ module('Acceptance | view-courses', function (hooks) {
116115
await catalogPage.visit();
117116

118117
assert.strictEqual(catalogPage.courseCards[0].actionText, 'Resume');
119-
assert.true(catalogPage.courseCards[0].hasProgressBar);
118+
assert.true(catalogPage.courseCards[0].hasProgressDonut);
120119
assert.strictEqual(catalogPage.courseCards[0].progressText, '0/55 stages');
121120
});
122121

tests/acceptance/view-tracks-test.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ module('Acceptance | view-tracks', function (hooks) {
4646
assert.strictEqual(catalogPage.trackCards[2].actionText, 'Start', 'expected third track to have start action');
4747
assert.strictEqual(catalogPage.trackCards[3].actionText, 'Start', 'expected fourth track to have start action');
4848

49-
assert.true(catalogPage.trackCards[0].hasProgressBar, 'expected first track to have progress bar');
49+
assert.true(catalogPage.trackCards[0].hasProgressDonut, 'expected first track to have progress donut');
5050
assert.strictEqual(catalogPage.trackCards[0].progressText, '1/86 stages');
51-
assert.strictEqual(catalogPage.trackCards[0].progressBarStyle, 'width:1%');
5251
});
5352

5453
test('it sorts course cards based on last push', async function (assert) {
@@ -122,7 +121,6 @@ module('Acceptance | view-tracks', function (hooks) {
122121
assert.strictEqual(catalogPage.trackCards[2].actionText, 'Start');
123122
assert.strictEqual(catalogPage.trackCards[3].actionText, 'Start');
124123
assert.strictEqual(catalogPage.trackCards[0].progressText, '15/15 stages');
125-
assert.strictEqual(catalogPage.trackCards[0].progressBarStyle, 'width:100%');
126124
});
127125

128126
test('it renders if user is not signed in', async function (assert) {
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { attribute, isPresent, text } from 'ember-cli-page-object';
1+
import { isPresent, text } from 'ember-cli-page-object';
22

33
export default {
44
actionText: text('[data-test-action-text]'),
5-
name: text('[data-test-course-name]'),
65
description: text('[data-test-course-description]'),
76
hasAlphaLabel: isPresent('[data-test-course-alpha-label]'),
87
hasBetaLabel: isPresent('[data-test-course-beta-label]'),
9-
hasFreeLabel: isPresent('[data-test-course-free-label]'),
10-
hasProgressBar: isPresent('[data-test-course-progress]'),
118
hasDifficultyLabel: isPresent('[data-test-course-difficulty-label]'),
9+
hasFreeLabel: isPresent('[data-test-course-free-label]'),
10+
hasProgressDonut: isPresent('[data-test-course-progress-donut]'),
11+
name: text('[data-test-course-name]'),
1212
progressText: text('[data-test-course-progress-text]'),
13-
progressBarStyle: attribute('style', '[data-test-course-progress-bar]'),
1413
};
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { attribute, isPresent, text } from 'ember-cli-page-object';
1+
import { isPresent, text } from 'ember-cli-page-object';
22

33
export default {
44
actionText: text('[data-test-action-text]'),
5-
name: text('[data-test-track-name]'),
65
description: text('[data-test-track-description]'),
7-
hasPopularLabel: isPresent('[data-test-popular-track-label]'),
8-
hasProgressBar: isPresent('[data-test-track-progress]'),
96
hasDifficultyLabel: isPresent('[data-test-track-difficulty-label]'),
7+
hasPopularLabel: isPresent('[data-test-popular-track-label]'),
8+
hasProgressDonut: isPresent('[data-test-track-progress-donut]'),
9+
name: text('[data-test-track-name]'),
1010
progressText: text('[data-test-track-progress-text]'),
11-
progressBarStyle: attribute('style', '[data-test-track-progress-bar]'),
1211
};

0 commit comments

Comments
 (0)