Skip to content

Commit 1ff014b

Browse files
authored
feat: Record passed status for challenges (laike9m#80)
1 parent 0fa2d80 commit 1ff014b

File tree

4 files changed

+171
-9
lines changed

4 files changed

+171
-9
lines changed

static/js/passed-state.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
class PassedState {
2+
_key = 'python-type-challenges';
3+
4+
/**
5+
* Initializing when there is no state in the local storage. If there is no state in the local storage, the initial state is required.
6+
* this function will check the new state and the old state whether is undefined or not and updated the old state based on new state.
7+
*
8+
* @param {object} newState - the initial state of the challenges which grouped by the level.
9+
* @returns void
10+
*/
11+
init(newState) {
12+
const oldState = this.get();
13+
// initialize the state when there is no state in the local storage.
14+
if (!oldState && !newState) {
15+
throw new Error('initial state is required when there is no state in the local storage.');
16+
}
17+
18+
// check new state and old state whether is undefined or not. and merge the new state to the old state.
19+
const state = this._checkAndMerge(oldState, newState);
20+
this._save(state);
21+
}
22+
23+
get() {
24+
const currentState = localStorage.getItem(this._key);
25+
return JSON.parse(currentState);
26+
}
27+
28+
/**
29+
* Save the state to the local storage with JSON format.
30+
* @param {object} state - the state contains the challenge name and whether the challenge is passed.
31+
*/
32+
_save(state) {
33+
localStorage.setItem(this._key, JSON.stringify(state));
34+
}
35+
36+
/**
37+
* Set the target challenge as passed in the state.
38+
*
39+
* @param {'basic' | 'intermediate' | 'advanced' | 'extreme'} level - the level of the challenge.
40+
* @param {string} challengeName - the name of the challenge.
41+
* @returns void
42+
*/
43+
setPassed(level, challengeName) {
44+
let state = this.get();
45+
46+
const challenges = state[level];
47+
for (const challenge of challenges) {
48+
if (challenge.name === challengeName) {
49+
challenge.passed = true;
50+
break;
51+
}
52+
}
53+
54+
this._save(state);
55+
}
56+
57+
/**
58+
* Merge the new state and the current state.
59+
* this function will compare the new state with the current state and finally overwrite the current state based on the new state:
60+
* - If the old key in the current state isn't in the new state, the old key will be removed from the current state.
61+
* - If the new key in the new state isn't in the current state, the new key will be added to the current state.
62+
*
63+
* @param {object} oldState - the current state stored in the local storage.
64+
* @param {object} newState - the latest state from the server.
65+
* @returns mergedState - the merged state.
66+
*/
67+
_checkAndMerge(oldState, newState) {
68+
if (!newState && !oldState) {
69+
throw new Error('one of the new state and the old state is required.');
70+
}
71+
72+
if (!newState && oldState) {
73+
return oldState;
74+
}
75+
76+
const state = {};
77+
for (const level in newState) {
78+
const challenges = [];
79+
for (const challengeName of newState[level]) {
80+
challenges.push({
81+
name: challengeName,
82+
passed: false
83+
});
84+
}
85+
state[level] = challenges;
86+
}
87+
88+
if (!oldState && newState) {
89+
return state;
90+
}
91+
92+
let mergedState = {};
93+
const levels = ['basic', 'intermediate', 'advanced', 'extreme'];
94+
95+
for (const level of levels) {
96+
// Initialize an empty array for merged challenges
97+
let mergedChallenges = [];
98+
99+
// Create a map for quick lookup of challenges by name
100+
const oldChallengesMap = new Map(oldState[level].map(challenge => [challenge.name, challenge]));
101+
const newChallengesMap = new Map(state[level].map(challenge => [challenge.name, challenge]));
102+
103+
// Add or update challenges from the newState
104+
for (const [name, newChallenge] of newChallengesMap.entries()) {
105+
let hasPassed = oldChallengesMap.get(name)?.passed || newChallenge.passed;
106+
mergedChallenges.push({ ...newChallenge, passed: hasPassed });
107+
}
108+
109+
// Set the merged challenges for the current level in the mergedState
110+
mergedState[level] = mergedChallenges;
111+
}
112+
113+
return mergedState;
114+
}
115+
}
116+
117+
const passedState = new PassedState();
118+
export default passedState;

templates/challenge.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
overflow-y: auto;
3333
padding-left: 8px;
3434
padding-right: 8px;
35+
width: 15vw;
3536
}
3637

3738
.sidebar-container .sidebar-actions {
@@ -196,6 +197,7 @@
196197
height: 100%;
197198
padding-left: 0px;
198199
margin-right: auto;
200+
width: auto;
199201
}
200202

201203
.sidebar-container .sidebar-actions {
@@ -232,6 +234,7 @@
232234

233235
.active-challenge {
234236
background-color: var(--primary-focus);
237+
border-radius: 8px;
235238
}
236239

237240
.CodeMirror {

templates/components/challenge_area.html

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
<div class="codemirror-container">
2424
<div id="editor">
2525
<a id="playground-link" target="_blank" rel="noopener noreferrer">
26-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4m-8-2l8-8m0 0v5m0-5h-5"/></svg>
26+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
27+
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
28+
d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4m-8-2l8-8m0 0v5m0-5h-5" />
29+
</svg>
2730
<span>Open Pyright Playground</span>
2831
</a>
2932
</div>
@@ -48,7 +51,9 @@
4851
</div>
4952
</div>
5053

51-
<script type="text/javascript">
54+
<script type="module">
55+
import passedState from "{{ url_for('static', filename='js/passed-state.js')}}";
56+
5257
/**
5358
* Render the code area with CodeMirror and Jinja2.
5459
*
@@ -108,6 +113,9 @@
108113
// add confetti effect when passed
109114
if (json.passed) {
110115
confetti.addConfetti()
116+
// passedState is defined in challenge_sidebar.html
117+
passedState.setPassed(level, name);
118+
document.getElementById(`${level}-${name}`).parentNode.classList.add('passed');
111119
}
112120
setTimeout(() => {
113121
document.getElementById('answer-link').style.display = 'block';
@@ -151,11 +159,11 @@
151159
}
152160

153161
// Make sure the current challenge is visible to user.
154-
activeChallengeInList = document.getElementById(`${level}-${name}`);
155-
activeChallengeInList.classList.add('active-challenge'); // Highlight
162+
let activeChallengeInList = document.getElementById(`${level}-${name}`);
163+
activeChallengeInList.parentNode.classList.add('active-challenge'); // Highlight
156164
}
157165

158-
codeUnderTest = {{code_under_test | tojson}};
159-
testCode = {{ test_code | tojson }};
166+
let codeUnderTest = {{code_under_test | tojson}};
167+
let testCode = {{test_code | tojson}};
160168
renderCodeArea(codeUnderTest, testCode, "{{level}}", "{{name}}");
161-
</script>
169+
</script>

templates/components/challenge_sidebar.html

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@
3636
display: none;
3737
}
3838

39+
.passed {
40+
position: relative;
41+
}
42+
43+
.passed::after {
44+
/* iconify: https://icon-sets.iconify.design/lets-icons/done-ring-round */
45+
content: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="24" height="24" viewBox="0 0 24 24"%3E%3Cg fill="none" stroke="%2366ba6f" stroke-linecap="round" stroke-width="2"%3E%3Cpath d="m9 10l3.258 2.444a1 1 0 0 0 1.353-.142L20 5"%2F%3E%3Cpath d="M21 12a9 9 0 1 1-6.67-8.693"%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E');
46+
display: inline-block;
47+
position: absolute;
48+
width: 24px;
49+
height: 24px;
50+
top: 50%;
51+
right: 0;
52+
transform: translate(-50%, -50%);
53+
transition: all 1.5s ease-out;
54+
}
3955

4056
@media only screen and (max-width: 800px) {
4157
.sidebar-toggle {
@@ -124,7 +140,24 @@ <h5 class="challenge-level">{{ level }}</h5>
124140
</nav>
125141
</aside>
126142

127-
<script>
143+
<script type="module">
144+
import passedState from "{{ url_for('static', filename='js/passed-state.js')}}";
145+
const initialState = {{ challenges_groupby_level | tojson }};
146+
passedState.init(initialState);
147+
148+
// Highlight the passed challenges when the page is loaded.
149+
let state = passedState.get();
150+
Object.keys(state).forEach(level => {
151+
state[level].forEach(challenge => {
152+
let id = `#${level}-${challenge.name}`;
153+
if (challenge.passed) {
154+
document.querySelector(id).parentNode.classList.add('passed');
155+
}
156+
})
157+
})
158+
</script>
159+
160+
<script type="text/javascript">
128161
const sidebarTogglers = document.querySelectorAll('.sidebar-toggle');
129162
const drawer = document.querySelector('.drawer');
130163

@@ -140,7 +173,7 @@ <h5 class="challenge-level">{{ level }}</h5>
140173
* @param {Event} event - The click event.
141174
*/
142175
function removeHighlight(event) {
143-
previousActiveChallenges = document.getElementsByClassName("active-challenge");
176+
let previousActiveChallenges = document.getElementsByClassName("active-challenge");
144177
for (c of previousActiveChallenges) {
145178
// Remove previously highlighted challenge in the list.
146179
c.classList.remove('active-challenge');

0 commit comments

Comments
 (0)