Contents

SpeedTyping: A Spring Boot Typing Test

SpeedTyping: A Spring Boot Typing Test

Source: github.com/jackaholy/SpeedTyping

Why?

A team project for a software engineering course. The goal was to ship something end-to-end (backend, database, frontend) using Spring Boot and JPA.

The product side was a typing test aimed at younger learners: pick a difficulty, type the passage, get a WPM score, land on a leaderboard.

What?

A Spring Boot web app backed by MySQL via JPA. Three controllers handle the home/leaderboard view, the typing page, and score submission. Levels are seeded from a SQL file with thirty short passages tagged EASY, MEDIUM, or HARD. Vanilla JS on the frontend runs the timer, colors characters as you type, and auto-submits when the passage is finished.

The core domain is two entities: Level (the passage) and Score (a username, timestamp, WPM, and FK to a level). A LeaderboardService returns the top N scores per difficulty. Bootstrap handles styling.

Code

Domain model

The data model is small. A Level owns many Scores, with difficulty as an enum persisted as a string:

@Entity
@Table(name = "level")
public class Level {
    @Id @GeneratedValue
    private Integer id;

    @OneToMany(mappedBy = "level")
    private Set<Score> scores;

    @Column(name = "levelname", nullable = false)
    private String levelname;

    @Column(name = "wordcount", nullable = false)
    private Integer wordcount;

    public enum LevelDifficulty { EASY, MEDIUM, HARD }

    @Enumerated(EnumType.STRING)
    @Column(name = "leveldifficulty")
    private LevelDifficulty leveldifficulty;

    @Column(name = "content", nullable = false, length = 65535)
    private String content;
}

Scoring a submission

When a typing test is submitted, the controller compares typed words to the level word-for-word, computes WPM from the elapsed time, and persists a Score. Only fully correct words count:

@PostMapping("/addData")
public String addDataPost(@Valid @ModelAttribute TypeTest typeTest, BindingResult result, RedirectAttributes attrs) {
    if (result.hasErrors()) return "leaderboard";
    Level testedLevel = null;
    try {
        Score score = new Score();
        score.setUsername(typeTest.getUsername());
        testedLevel = contentService.findByLevelid(typeTest.getLevelId());
        score.setLevel(testedLevel);
        score.setDate(Calendar.getInstance().getTime());
        double time = typeTest.getTime();
        int correctWordsTyped = calculateCorrectWordsTyped(typeTest.getTypedContent(), testedLevel.getContent());
        double wordsPerMinute = (correctWordsTyped / time) * 60;
        score.setWpm(wordsPerMinute);
        contentService.saveScore(score);
    } catch (Exception ex) {
        result.addError(new ObjectError("globalError", "Failed to save data into database."));
    }
    return testedLevel != null
        ? "redirect:/leaderboard?difficulty=" + testedLevel.getLeveldifficulty()
        : "leaderboard";
}

Top-N leaderboard

The leaderboard service pulls every score for a difficulty, sorts by WPM descending in memory, and trims to N. Fine at this scale — there are 30 levels and a small number of submissions — but it’s the obvious thing to push into a JPQL query if the table ever grew:

@Override
public List<Score> getNScoresForDifficultySortByWpm(Level.LevelDifficulty leveldifficulty, int n) {
    if (leveldifficulty == null) return new LinkedList<>();
    if (n < 0) throw new IllegalArgumentException("n must be greater than or equal to 0");
    List<Score> diffLevels = getScoresForLeveldifficulty(leveldifficulty);
    diffLevels.sort(Comparator.comparing(Score::getWpm).reversed());
    List<Score> subList = diffLevels.subList(0, Math.min(n, diffLevels.size()));
    final int DECIMALS = 1;
    for (Score score : subList) {
        score.setWpm(Math.round(score.getWpm() * Math.pow(10, DECIMALS)) / Math.pow(10, DECIMALS));
    }
    return subList;
}

Frontend feedback and auto-submit

Each character in the target passage gets its own <span> so it can be recolored on every keystroke. A second script runs the timer and, once the user’s word count and final-word length match the target, auto-submits the form with the elapsed seconds:

userContent.addEventListener("input", () => {
    if (interval === null) {
        interval = setInterval(displayTimer, 10);
    }

    let userSpaces = countSpaces(userContent.value);
    let targetSpaces = countSpaces(targetString);

    if (userSpaces >= targetSpaces) {
        let userWords = userContent.value.split(' ');
        let targetWords = targetString.split(' ');
        let userLastWord = userWords[userWords.length - 1];
        let targetLastWord = targetWords[targetWords.length - 1];

        if ((userWords.length === targetWords.length && userLastWord.length === targetLastWord.length)
            || userWords.length > targetWords.length + 1) {
            let form = document.getElementById('testForm');
            let totalTime = minutes * 60 + seconds + milliseconds / 1000;
            const timeInput = document.createElement('input');
            timeInput.type = 'hidden';
            timeInput.name = 'time';
            timeInput.value = totalTime;
            form.appendChild(timeInput);
            form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
            clearInterval(interval);
            interval = null;
        }
    }
});

Results

End to end the app does what it should. You pick a difficulty, type the passage, get a score, and show up on the leaderboard. Unit tests cover the model classes and both service implementations.

What stuck with me is how much of a Spring Boot project is plumbing. Controllers hand data to templates, JPA annotations map entities to tables, and the actual interesting logic is a small fraction of the code. If this project kept growing, the first two things I would revisit are the leaderboard’s in-memory sort and the per-character span approach on the frontend.