projects / studytool

StudyTool

Fill-in-the-blank study app that turns a JSON question bank into practice and timed sessions, with difficulty levels, a word bank, fuzzy answer checking and weak questions first.

fig. 0x07pen and book

Overview

StudyTool is a small study app I built for myself over a day and a half in January 2026. You give it a JSON file of questions and answers, and it turns each answer into a fill-in-the-blank exercise. It keeps a score for every question and brings the ones you keep getting wrong back to the front.

It runs locally: a React frontend and a small Node backend that keeps everything in one SQLite file. The GitHub description still says it needs no database. That is only half true: you don't run a database server, but the backend does store questions, attempts and sessions in study.db.

A question in typing mode: some words of the answer are blanked
A question in typing mode: some words of the answer are blanked

The problem

My uni modules come with a lot of definitions that need to be recalled close to word for word, and flashcards weren't working for me. Flipping a card and thinking "yes, I knew that" is too easy. I wanted something that made me actually produce the words, didn't care about the odd typo, and spent more time on the questions I was bad at than on the ones I already knew. The sample bank in the repo is 214 questions from my ethical hacking module, which is what I built it for.

How it works

You upload a question bank (a list of question / answer pairs). The frontend checks the shape, and the backend saves the set and all its questions in a single SQLite transaction. Starting a session asks the backend for the set in "smart order".

One question, from the bank to the score

Blanking. The answer is split into words, spaces and punctuation, and a share of the words is blanked. The difficulty decides the share: easy blanks 20%, medium 40%, hard 60% and extreme 80%, always at least one word. In word bank mode you pick from the missing words plus about half as many decoys, which are taken from the other answers in the same set so they look plausible.

Checking. Each blank is compared with the real word using Levenshtein distance, ignoring case. If your answer is at least 80% similar it counts, so "authorized" for "authorised" is fine, but a different word isn't.

Picking the next question. Every question has a score built from its last five attempts, with recent attempts counting more:

study_tools_backend/src/lib/score.tsview source ↗
const WEIGHTS = [1.0, 0.8, 0.6, 0.4, 0.2]/** * Calculate weighted score from recent attempts. * Uses weights [1.0, 0.8, 0.6, 0.4, 0.2] for most recent 5 attempts. * Returns null if no attempts, otherwise returns weighted average (0-1). */export function calculateWeightedScore(attempts: Attempt[]): number | null {  if (attempts.length === 0) {    return null  }  const recentAttempts = attempts.slice(0, 5)  let weightedSum = 0  let totalWeight = 0  for (let i = 0; i < recentAttempts.length; i++) {    const weight = WEIGHTS[i]    weightedSum += recentAttempts[i].correct ? weight : 0    totalWeight += weight  }  return weightedSum / totalWeight}

The weightsL1 mean that getting a question right today matters more than getting it wrong a week ago. A question you've never answered has no score at allL9-11 rather than zero, because that's a different situation. The order is then a weighted shuffle, not a sort:

study_tools_backend/src/lib/ordering.tsview source ↗
export function getWeight(score: number | null): number {  return 1 - (score ?? 0.5)}// ...export function createSmartOrder(scores: (number | null)[]): number[] {  const indices = scores.map((_, i) => i)  const remaining = [...indices]  const result: number[] = []  const weights = scores.map(getWeight)  while (remaining.length > 0) {    const remainingWeights = remaining.map((i) => weights[i])    const selectedIdx = weightedRandomIndex(remainingWeights)    result.push(remaining[selectedIdx])    remaining.splice(selectedIdx, 1)  }  return result}

A question's weightL2 is one minus its score, and unseen questions sit in the middle at 0.5. The loopL11-16 draws questions one at a time in proportion to their weight, so weak questions usually come first but strong ones still turn up now and then. A plain sort would show the same order every time, and you end up learning the order instead of the answers. Timed mode uses the same weights but picks one question at a time and leaves out the most recently seen half of the set, so a question can't come straight back.

Later in the build I added SM-2 spaced repetition on top: every attempt updates an ease factor and a next review date, and the home page shows how many questions are due today.

Running it. npm run dev starts both halves. For Docker there's a dev compose file and a production one, where nginx serves the built frontend and the API address is put in when the container starts, so one image works wherever the backend lives. The SQLite file goes in DATA_DIR, so the data survives rebuilds.

Outcome

I used it for my own revision, and it does what I wanted: it's harder to fool yourself with blanks than with flashcards, and the fuzzy matching means a typo doesn't count against you. The screenshots on this page are from a local run with the ethical hacking bank loaded.

Things I'd fix:

  • Two scoring systems. The weighted score only looks at whether an attempt was right or wrong, while SM-2 gets the partial score (3 of 5 blanks). They should come from one model.
  • Duplicated ordering. The ordering code exists in both the frontend and the backend. It should live in one place.
  • Shuffling. I shuffle with sort(() => Math.random() - 0.5), which is biased. A Fisher-Yates shuffle is just as short.
  • No tests. For logic this small and this easy to get subtly wrong, I should have written tests.