How to Make Studying Effective With Spaced Repetition (and NodeJS!)
Building a command-line study app with NodeJS
Introduction
Let's be honest, as a student it isn't all that exciting when it comes to studying some material, and coming across content that's already etched into the back of your brain, that you could recall in your sleep. In this article, I will share with you how to implement a deck of spaced repetition flashcards, similar to apps like RemNote or Anki.
But what's spaced repetition, and how does it work? Spaced repetition is a method of leaving calculated intervals between reviewing content, so you are forced to recall information once it's less fresh in your memory. The benefits of this method are that it allows for information to be kept in your long term memory for longer, so you spend less time re-reviewing it. What's more is that there is lots of research published on the topic, and is applied to various learning scenarios such as language learning and exam preparation which you may not even think about.
For the purpose of this article, we'll be implementing the SM-2 algorithm, developed by Dr Piotr Wozniak in the late '80s. The advantage to this algorithm is that the later review date for flashcards is dependent on how well you were able to recall the information, referred to as the quality of response, meaning you see easier content less often and more difficult content more often.
Setting up
First, open up a new directory and let's set up an NPM project; for this project we'll be using TypeScript,
npm init -y
npm i -D typescript
We'll also install date-fns as we're going to need it later,
and uuid
, in order to uniquely identify different flashcards,
npm i date-fns uuid
Then, create a new file named flashcard.ts
and open it in your favourite editor.
Defining a flashcard
We'll first set up an interface to control the attributes of a flashcard object, which will be a regular JS object. (note we'll be using the number
type for Date
objects as date-fns
will no longer support string arguments)
// flashcard.ts
export interface IFlashcard {
id: string; // To uniquely identify flashcards
question: string; // The text that goes on the front of the card
content: string; // The answer to the question
easiness: number; // Reflects how easy it is to recall the content
interval: number; // The number of days after which a review is need
repetitions: number; // How many times the flashcard has been recalled correctly in a row
nextReview: number; // The earliest date we can review the flashcard
}
Then, let's create a function which will initialise a new flashcard object, using default values stated by Piotr Wozniak here.
export const createFlashcard = (
question: string,
content: string
): IFlashcard => {
return {
id: uuid.v4(),
question,
content,
easiness: 2.5,
interval: 0,
repetitions: 0,
nextReview: new Date().getTime(), // So that we review it on the day of creation or later
};
};
And now we can implement the SM2 algorithm with a function that takes 2 inputs: quality (how well the response was answered) and the flashcard object (so that its attributes can be updated),
export const reviewFlashcard = (
flashcard: IFlashcard,
quality: number,
reviewDate?: number
) => {
flashcard.repetitions++;
let interval: number;
switch (flashcard.repetitions) {
case 1:
interval = 1;
break;
case 2:
interval = 6;
break;
default:
interval = Math.round(flashcard.interval * flashcard.easiness);
}
let easiness =
flashcard.easiness +
(0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
if (easiness < 1.3) {
easiness = 1.3;
}
if (quality < 3) {
interval = 1;
flashcard.repetitions = 1;
} else {
flashcard.easiness = easiness;
}
flashcard.interval = interval;
// Gets the next review date as the current date, `interval` days later
flashcard.nextReview = getIntervalDate(interval, reviewDate);
return quality < 4;
};
The function returns true
if the quality is lower than 4, so that after each repetition session, the flashcards which return true
will be shown again, until the value is false
.
And here's how we'll get the interval date using date-fns
, which was installed earlier,
// Uses optional `date` argument
const getIntervalDate = (interval: number, date?: number) => {
return addDays(date ?? new Date().getTime(), interval).getTime();
};
Example
Now that that's all sorted, how would we use this in a real application. Well, we can start by creating a sample file, let's go with test.ts
, and we can use the following:
import { IFlashcard, createFlashcard, reviewFlashcard } from "./flashcard";
import { formatISO } from "date-fns"; // So that we can see the dates nicely
// Return a Date object in the format yyyy-MM-dd
const formatDate = (date: number) => {
return formatISO(date, { representation: "date" });
};
// Output the current state of the updated flashcard
const printStatus = () => {
console.log(flashcard);
console.log(`The easiness factor has changed to ${flashcard.easiness}`);
console.log(`The next review date is ${formatDate(flashcard.nextReview)}`);
if (revisit) {
console.log(
"This flashcard needs revisiting at the end of today's session"
);
} else {
console.log("This flashcard does not need revisiting");
}
console.log();
};
let question = "When was ECMAScript 6 first released?";
let content = "June 2015";
// Creating a flashcard
let flashcard: IFlashcard = createFlashcard(question, content);
console.log(flashcard);
// Returns the default values from `flashcard.ts`
// Let's review the card
let quality = 5; // perfect response
let currentDate = new Date().getTime();
// Make a review and mutate the flashcard
let revisit = reviewFlashcard(flashcard, quality, currentDate);
printStatus();
// Travelling in time to the next review date
currentDate = flashcard.nextReview;
// The information just slipped away from us
quality = 2; // incorrect response; the correct one remembered
revisit = reviewFlashcard(flashcard, quality, currentDate);
printStatus();
currentDate = flashcard.nextReview;
// Now we get slightly better
quality = 3; // correct response recalled with serious difficulty
revisit = reviewFlashcard(flashcard, quality, currentDate);
printStatus();
// Final review day
currentDate = flashcard.nextReview;
quality = 4; // correct response after a hesitation
revisit = reviewFlashcard(flashcard, quality, currentDate);
printStatus();
And here is the output:
{
id: 'eed168b1-8d62-4cef-895d-5c923d436c03',
question: "When was ECMAScript 6 first released?",
content: 'June 2015',
easiness: 2.5,
interval: 0,
repetitions: 0,
nextReview: 2022-07-19T20:31:44.383Z
}
The easiness factor has changed to 2.6
The next review date is 2022-07-20
This flashcard does not need revisiting
The easiness factor has changed to 2.6
The next review date is 2022-07-21
This flashcard needs revisiting at the end of today's session
The easiness factor has changed to 2.46
The next review date is 2022-07-27
This flashcard needs revisiting at the end of today's session
The easiness factor has changed to 2.46
The next review date is 2022-08-11
This flashcard does not need revisiting
So what can we turn this all into something somewhat interactable? Let's create a deck so that we can group together flashcards and even decipher which ones would need reviewing on any given day.
Defining the deck
Our flashcard deck should be capable of letting the user save and load their flashcards to and from the computer, for convenience. For this, we'll use the fs
module from NodeJS. Create a new file named deck.ts
in the same directory as the flashcard.ts
file.
Begin by importing the things we need,
// deck.ts
import { differenceInCalendarDays, isToday } from "date-fns";
import { readFileSync, writeFileSync } from "fs";
import { IFlashcard, reviewFlashcard } from "./flashcard";
and let's define these 4 variables,
export let lastSeen: Date = new Date(); // The last date the deck had been loaded
export let stack: string[] = []; // The current flashcards to be reviewed for the day
export let flashcards: {
[key: string]: IFlashcard;
} = {}; // All the flashcards from a deck file
export let source: string; // The current filepath to the deck file
The first 3 of these we will store in a file each time the user finishes the session, so that they can be loaded later, so that their session can continue where it was left off.
Saving and loading
Now when we load the deck's data from a file, we'll store the path to the file used, and use it when saving back to the file (note that we'll be reading/writing with the JSON format),
export const load = (filePath: string) => {
source = filePath;
const rawData = readFileSync(filePath, "utf-8");
const data = JSON.parse(rawData);
// Updating the deck in memory with data from the file
lastSeen = data.lastSeen;
if (isToday(lastSeen)) {
// Use the previously saved stack
stack = data.stack;
} else {
// The stack resets after each day
stack = getInitialStack();
}
flashcards = data.flashcards;
};
export const save = (filePath?: string) => {
// Save all deck data to `filePath` or the current deck's filePath
filePath = filePath ?? source;
if (filePath === undefined) {
// This error can be caught inside the main application code
throw new Error("No flashcards file has been opened.");
}
const data = {
lastSeen: new Date(),
stack,
flashcards,
};
writeFileSync(filePath, JSON.stringify(data), "utf-8");
};
When the user starts a session, the IDs of the flashcards they need to practice will be stored in a stack. As seen in the load
function, the stack from any previous session on a previous day should be reset to avoid accumulation, but if a previous session had been started on the same day, that previous stack will be used.
For the record, we can decide which flashcards will be on the stack initially like so,
const getInitialStack = (): string[] => {
let queue: string[] = [];
for (let flashcard of Object.values(flashcards)) {
// Is the next review date for the flashcard in the past?
if (differenceInCalendarDays(new Date(), flashcard.nextReview) <= 0) {
queue.push(flashcard.id);
}
}
return queue;
};
Managing the stack
A flashcard will be removed from the top of the stack and presented to the user on each iteration of the main program.
export const getNextQuestion = (): IFlashcard | null => {
// Get the ID at the start of the stack and remove it
const nextFlashcardId = stack.shift();
if (nextFlashcardId) {
return flashcards[nextFlashcardId];
}
return null;
};
They will then asses the quality of recall, pushing the flashcard back to the stack (at the bottom) if quality < 4
as mentioned previously.
export const assessFlashcard = (flashcardId: string, quality: number) => {
if (reviewFlashcard(flashcards[flashcardId], quality)) {
stack.push(flashcardId);
}
};
Also, we need a function to add a flashcard to the deck, which will be useful for when flashcards are being created,
export const addFlashcard = (flashcard: IFlashcard) => {
const flashcardId = flashcard.id;
flashcards[flashcardId] = flashcard;
stack.push(flashcardId);
};
... along with a function to clear the flashcards (and the stack) if necessary,
export const clearFlashcards = () => {
// Go through each key of the object
for (let flashcardId of Object.keys(flashcards)) {
delete flashcards[flashcardId];
}
// Removes all items
stack.length = 0;
};
Now let's see how we can tie everything together into a command-line application.
Building the CLI
Imports
import { formatISO } from "date-fns";
import * as readline from "readline";
import * as deck from "./deck";
import { createFlashcard } from "./flashcard";
We're using the readline
module as we'll need to get console input from the user, and date-fns
again to format a date, like in our previous example.
Constants
We'll then define these 2 constants,
const QUIT_WORD = "quit";
This avoids us having to reuse the same string literal.
const qualityMap = [5, 3, 1, 0];
This is an array with values from 0 - 5, which are the quality assessment values used in the algorithm, so that the user can only choose between 4 values (1st would represent 5, 2nd would be 3, 3rd would be 1 and 4th would be 0), to simplify usage.
Setting up the interface
Then create a readline
interface, and add a close
handler for when the user interrupts the program with Ctrl-C
or when they enter "quit" in the mainloop, which would call the quit
function.
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Can only save if a deck has been loaded from a file initially
rl.on("close", () => {
if (deck.source) deck.save();
console.log("Thank you. Good bye!");
});
// Closes the input stream to save the deck file, and exits NodeJS
const quit = () => {
rl.close();
return process.exit(22);
};
Creating the mainloop
Start by creating a function that outputs a list of helpful commands, it also helps us see what will be implemented later,
const printHelp = () => {
console.log(`
help : Outputs all information about commands
quit : Exits the program
stats : Outputs information about the current deck
load (filePath) : Loads a deck file from filePath
save (filePath?) : Saves the deck to the file at filePath
create : Starts a dialog to create a flashcard
clear : Clears all flashcards and empties the deck
start : Starts a study session (use "quit" to end)
`);
};
... and now another one to print the deck's stats,
const printStats = () => {
const source = deck.source || "None";
const totalFlashcards = Object.keys(deck.flashcards).length;
const lastOpened = formatISO(deck.lastSeen, { representation: "date" });
const currentStack = deck.stack.length;
console.log(`
Source: ${source}
Total flashcards: ${totalFlashcards}
Last opened: ${lastOpened}
Current stack: ${currentStack}
`);
};
Let's now create the function which will be run continuously to form the mainloop:
const run = async () => {
// Displays a prompt to the user
var answer = await prompt("> ");
// Parse the user's input into the needed parts
let parts = answer.split(" ");
if (parts.length === 0) return;
const command = parts[0];
const args = parts.slice(1);
try {
await executeCommand(command, args);
} catch {
console.log(`Invalid arguments provided.`);
}
};
We also define the prompt
function to hide the low-level details of rl.question
and conveniently provide a method that can be used with await
.
const prompt = (prompt: string): Promise<string> => {
return new Promise((resolve, reject) => {
rl.question(prompt, resolve);
});
};
And the executeCommand
function contains all the code where we will be mainly accessing the deck and manipulating our stack.
const executeCommand = async (command: string, args: string[]) => {
switch (command) {
case "help":
printHelp();
break;
case QUIT_WORD:
quit();
break;
case "stats":
printStats();
break;
// extra case statements go here
default:
console.log("Invalid command.");
break;
}
Here, each case statement will be a command listed from the help
function's output.
We'll start by defining save
and load
,
//
case "load":
if (args.length !== 1) {
throw new Error();
}
deck.load(args[0]);
console.log("Deck loaded.");
break;
case "save":
// There can only be 1 or no arguments provided
if (args.length > 1) {
throw new Error();
}
deck.save(args[0]);
console.log("Deck saved.");
break;
//
And note that once an error is thrown in this function, it will be handled by the run
function, and we will know that the error is due to the arguments provided being of an insufficient length, or they cause a further error when the deck is involved.
Here are 2 more, which make further use of our convenient prompt
function:
//
case "create":
const question = await prompt("Question: ");
const content = await prompt("Content: ");
const newFlashcard = createFlashcard(question, content);
deck.addFlashcard(newFlashcard);
console.log("Flashcard created.");
break;
case "clear":
const response = await prompt("Confirm (y/n): ");
if (response.toLowerCase() === "y") {
deck.clearFlashcards();
console.log("Deck cleared.");
}
break;
//
To top the list of commands implemented we will implement start
:
//
case "start":
if (Object.keys(deck.flashcards).length === 0) {
console.log("No flashcards have been added.");
return;
}
if (deck.stack.length === 0) {
console.log("Stack is empty. Come back later.");
return;
}
console.log("Session started.");
while (true) {
const flashcard = deck.getNextQuestion();
if (flashcard === null) {
console.log("Stack is empty. Come back later.");
break;
}
try {
// Displays the question, user sees it and thinks of the answer
let response = await prompt(flashcard.question + "\n");
// Allows exiting from flashcard session with `QUIT_WORD`
if (response === QUIT_WORD) break;
// Answer is displayed to the user
console.log(flashcard.content);
const quality = await prompt(
`Recall quality (1 - ${qualityMap.length}): `
);
if (quality === QUIT_WORD) break;
// If the input is not numerical
if (isNaN(Number(quality))) throw new Error();
// Converts string to number, and changes (1 -> n) to (0 -> n - 1)
deck.assessFlashcard(
flashcard.id,
qualityMap[parseInt(quality) - 1]
);
} catch {
console.log("Invalid arguments provided.");
}
}
break;
//
And to finally tie everything together (now outside of the executeCommand
function),
(async () => {
while (true) {
await run();
}
})();
Testing it out
Now we can compile our code to see it in action:
npx tsc main.ts
node main.js
Here's an example of my test run:
Conclusion
If you've read this to the end, I really appreciate it, and you can find the code on my GitHub.