Tier 1 — Beginner

Flashcard App

Study with digital flashcards — spaced repetition scoring, shuffle, JSON persistence, and a quiz loop.

🐍 Python 🟨 JavaScript

1. The Problem

Build a flashcard study tool: add cards (question + answer), quiz yourself through a deck, and track how well you know each card (easy/hard). Cards you mark as hard appear more often — basic spaced repetition.

Why this project? Array shuffling (Fisher-Yates), weighted selection, JSON persistence, and an interactive quiz loop — the same patterns behind every learning app from Anki to Duolingo.

2. How to Think About This

Each card has a score: starts at 1, increases on "easy", decreases on "hard" (minimum 1). Weighted selection: expand the deck so cards with score=1 appear once, score=3 appear three times, then shuffle. The more you struggle, the more often a card comes up.

3. The Build

flashcards.mjs
import * as readline from "readline/promises";
import { stdin, stdout } from "process";
import { readFileSync, writeFileSync, existsSync } from "fs";

const DATA = "cards.json";

export function shuffle(arr) {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

export function weightedDeck(cards) {
  // Cards with lower score (harder) appear more times
  const maxScore = Math.max(...cards.map(c => c.score), 1);
  const expanded = cards.flatMap(c => Array(maxScore - c.score + 1).fill(c));
  return shuffle(expanded);
}

function load() {
  if (!existsSync(DATA)) return [];
  try { return JSON.parse(readFileSync(DATA, "utf-8")); } catch { return []; }
}
function save(cards) { writeFileSync(DATA, JSON.stringify(cards, null, 2)); }

const [,, cmd, ...args] = process.argv;
let cards = load();

if (cmd === "add") {
  const [q, ...aParts] = args;
  const a = aParts.join(" ");
  if (!q || !a) { console.error('Usage: node flashcards.mjs add "Question?" "Answer"'); process.exit(1); }
  cards.push({ q: q.trim(), a: a.trim(), score: 1 });
  save(cards);
  console.log(`Card added. Total: ${cards.length}`);

} else if (cmd === "list") {
  if (!cards.length) { console.log("No cards yet."); process.exit(0); }
  cards.forEach((c, i) => console.log(`  ${i+1}. [score:${c.score}] Q: ${c.q}`));

} else if (cmd === "study") {
  if (!cards.length) { console.error("No cards yet. Add some first."); process.exit(1); }
  const rl = readline.createInterface({ input: stdin, output: stdout });
  const deck = weightedDeck(cards);
  let correct = 0;

  console.log(`
Studying ${cards.length} cards (${deck.length} in weighted deck). Press Enter to reveal.
`);

  for (const card of deck) {
    console.log(`Q: ${card.q}`);
    await rl.question("  [Enter to reveal] ");
    console.log(`A: ${card.a}`);
    const rating = await rl.question("  Easy (e) / Hard (h) / Skip (s): ");
    const orig = cards.find(c => c.q === card.q);
    if (rating.toLowerCase() === "e") { orig.score = Math.min(orig.score + 1, 5); correct++; }
    if (rating.toLowerCase() === "h") { orig.score = Math.max(orig.score - 1, 1); }
    console.log();
  }
  rl.close();
  save(cards);
  console.log(`Session complete: ${correct}/${deck.length} marked easy.`);

} else {
  console.log("Commands: add <question> <answer> | list | study");
}

4. Test & Prove

import { shuffle, weightedDeck } from "./flashcards.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log("  ✓",d);p++;}catch(e){console.log("  ✗",d,"—",e.message);f++;}}
const cards = [
  { q:"Q1", a:"A1", score:1 },
  { q:"Q2", a:"A2", score:3 },
];
test("shuffle returns same length",   () => { if(shuffle([1,2,3]).length!==3) throw new Error(); });
test("shuffle doesn't mutate orig",   () => { const a=[1,2,3]; shuffle(a); if(a[0]!==1||a[1]!==2||a[2]!==3) throw new Error(); });
test("weighted: score=1 appears more",() => {
  const deck = weightedDeck(cards);
  const q1Count = deck.filter(c=>c.q==="Q1").length;
  const q2Count = deck.filter(c=>c.q==="Q2").length;
  if(q1Count <= q2Count) throw new Error(`Q1:${q1Count} Q2:${q2Count}`);
});
test("weightedDeck returns shuffled array", () => { if(!Array.isArray(weightedDeck(cards))) throw new Error(); });
test("score-1 card expands most",    () => {
  const cs = [{q:"hard",a:"",score:1},{q:"easy",a:"",score:5}];
  const d = weightedDeck(cs);
  if(d.filter(c=>c.q==="hard").length < d.filter(c=>c.q==="easy").length) throw new Error();
});
console.log(`
${p} passed, ${f} failed`);

5. The Interface

node flashcards.mjs add "What does === check?" "Value AND type"
node flashcards.mjs add "What is a closure?" "A function that remembers its outer scope"
node flashcards.mjs list
node flashcards.mjs study

6. Run It

node flashcards.mjs add "What is hoisting?" "Moving declarations to the top of scope"
node flashcards.mjs study
node flashcards.test.mjs

🎯 Try this next

  1. Add a --deck <name> flag so you can have multiple topic decks
  2. Add a due-date system (SM-2 algorithm) — cards come due based on review interval
  3. Build a browser version with card flip animation using CSS transitions
  4. Import cards from a CSV file: Question,Answer format

What you practised

Fisher-Yates shuffle, flatMap for weighted array expansion, spaced repetition scoring logic, JSON persistence, and designing an interactive CLI loop with multiple commands.

Reference: Arrays · Async & Promises · Error Handling

Try It Yourself