Students match the parts of a finished to-do app to the course's pieces, find a count that goes stale, build the app in three milestones with fading support, then add a feature of their own.
Load these into Waypoints Prepare: choose Load URL and paste the copied address, or download the file and import it. In Prepare, the worksheet's Teacher edition shows the answers; the student copy hides them.
Plan 10 min · Milestone 1 15 min · Milestone 2 25 min · Milestone 3 30 min · Make it yours 20 min (two lessons)
Read the challenge primer first, then build milestone 1 yourself. There is nothing new here apart from one line: v-model on a tick box joins it to true or false. Each milestone recalls a lesson: adding (events and inputs, lesson 3), ticking and counting (computed, lesson 4), the component (lesson 5). Support fades on purpose: milestone 1 gives most of the code, milestone 2 an outline as comments, milestone 3 only the goals. Finishing this lesson marks Challenge complete on the course page.
The count and Clear done use a for loop over the list, which students know from p5.js and the DOM course. Real Vue code would often use filter, which is fine as a stretch. The tick box inside the component changes task.done on the object it was given: it works because the parent and the card share the same object, but in larger apps a component sends an event up instead (emit), which this course leaves out.
Milestone 3 is meant to be hard: turning working code into a component. Ask "what does one card need to be given?" before showing anything.
| What you will see | A question to ask | The fix |
|---|---|---|
| The count does not change when a box is ticked. | Is left a computed, and does the tick box have v-model on t.done? | Wrap the count in computed(() => { … }) and join the box with v-model="t.done". |
| Add puts an empty task in the list. | What does addTask check before pushing? | if (newTask.value !== "") { … } |
| The component shows nothing. | Is it registered before app.mount, is task in its props, and does the tag pass :task="t"? | All three: register, list the prop, pass it with a colon. |
One possible solution for each task. Other correct programs are fine.
<div id="app">
<h1>To do</h1>
<input v-model="newTask" placeholder="Something to do">
<button @click="addTask">Add</button>
<ul>
<li v-for="t in tasks">{{ t.text }}</li>
</ul>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
const app = createApp({
setup() {
const tasks = ref([
{ text: "Feed the cat", done: false },
{ text: "Pack my bag", done: true }
]);
const newTask = ref("");
function addTask() {
if (newTask.value !== "") {
tasks.value.push({ text: newTask.value, done: false });
newTask.value = "";
}
}
return { tasks, newTask, addTask };
}
});
app.mount("#app");
</script>
<div id="app">
<h1>To do</h1>
<input v-model="newTask" placeholder="Something to do">
<button @click="addTask">Add</button>
<ul>
<li v-for="t in tasks"><label><input type="checkbox" v-model="t.done"> {{ t.text }}</label></li>
</ul>
<p>{{ left }} left to do</p>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
const app = createApp({
setup() {
const tasks = ref([
{ text: "Feed the cat", done: false },
{ text: "Pack my bag", done: true }
]);
const newTask = ref("");
function addTask() {
if (newTask.value !== "") {
tasks.value.push({ text: newTask.value, done: false });
newTask.value = "";
}
}
const left = computed(() => {
let count = 0;
for (const t of tasks.value) {
if (!t.done) {
count++;
}
}
return count;
});
return { tasks, newTask, left, addTask };
}
});
app.mount("#app");
</script>
<div id="app">
<h1>To do</h1>
<input v-model="newTask" placeholder="Something to do">
<button @click="addTask">Add</button>
<ul>
<todo-item v-for="t in tasks" :task="t"></todo-item>
</ul>
<p>{{ left }} left to do</p>
<button @click="clearDone">Clear done</button>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
const app = createApp({
setup() {
const tasks = ref([
{ text: "Feed the cat", done: false },
{ text: "Pack my bag", done: true }
]);
const newTask = ref("");
const left = computed(() => {
let count = 0;
for (const t of tasks.value) {
if (!t.done) {
count++;
}
}
return count;
});
function addTask() {
if (newTask.value !== "") {
tasks.value.push({ text: newTask.value, done: false });
newTask.value = "";
}
}
function clearDone() {
const keep = [];
for (const t of tasks.value) {
if (!t.done) {
keep.push(t);
}
}
tasks.value = keep;
}
return { tasks, newTask, left, addTask, clearDone };
}
});
app.component("todo-item", {
props: ["task"],
template: `<li><label><input type="checkbox" v-model="task.done"> {{ task.text }}</label></li>`
});
app.mount("#app");
</script>
<div id="app">
<h1>To do</h1>
<input v-model="newTask" placeholder="Something to do">
<button @click="addTask">Add</button>
<ul>
<todo-item v-for="t in tasks" :task="t"></todo-item>
</ul>
<p>{{ left }} left to do</p>
<button @click="clearDone">Clear done</button>
<p v-if="tasks.length > 0 && left === 0">All done! 🎉</p>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
const app = createApp({
setup() {
const tasks = ref([
{ text: "Feed the cat", done: false },
{ text: "Pack my bag", done: true }
]);
const newTask = ref("");
const left = computed(() => {
let count = 0;
for (const t of tasks.value) {
if (!t.done) {
count++;
}
}
return count;
});
function addTask() {
if (newTask.value !== "") {
tasks.value.push({ text: newTask.value, done: false });
newTask.value = "";
}
}
function clearDone() {
const keep = [];
for (const t of tasks.value) {
if (!t.done) {
keep.push(t);
}
}
tasks.value = keep;
}
return { tasks, newTask, left, addTask, clearDone };
}
});
app.component("todo-item", {
props: ["task"],
template: `<li><label><input type="checkbox" v-model="task.done"> {{ task.text }}</label></li>`
});
app.mount("#app");
</script>
Stretch: Save the tasks between visits with localStorage (watch the list, then JSON.stringify it); use filter for the count; send a delete event up from the component with emit.
Support: Pair students for milestone 3: one writes the component, one writes Clear done.
Why is the number left a computed and not a ref?
It is worked out from the list, so it must be counted again whenever a task is added, ticked or cleared; computed does that, a ref would go out of step.
The optional lesson builds the same app in Vue, Preact and Svelte. Real projects use a build tool (npm create vue@latest) and single-file .vue components; the ideas stay the same. Assessment idea: students annotate a printout of their app with the lesson each part came from.