How to Make Studying Effective With Spaced Repetition (and NodeJS!)

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:

Screenshot 2022-07-20 221349 (1).png

Conclusion

If you've read this to the end, I really appreciate it, and you can find the code on my GitHub.

Did you find this article valuable?

Support CS310 by becoming a sponsor. Any amount is appreciated!